update sync data from site dashboard and testing closing cycles
This commit is contained in:
1 parent
36639d8c38
commit
f49e72592c
42 files changed
+1966
-688
No files matched your search
@@ -0,0 +1,45 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("farms", "0019_site_id_slug"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="kandang",
|
||||
name="source_kandang_id",
|
||||
field=models.BigIntegerField(
|
||||
blank=True,
|
||||
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="flock",
|
||||
name="source_flock_id",
|
||||
field=models.BigIntegerField(
|
||||
blank=True,
|
||||
help_text="flock_id on the farm-location dashboard-cpsp deployment",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="kandang",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("source_kandang_id__isnull", False)),
|
||||
fields=("site", "source_kandang_id"),
|
||||
name="uniq_kandang_site_source",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="flock",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("source_flock_id__isnull", False)),
|
||||
fields=("kandang", "source_flock_id"),
|
||||
name="uniq_flock_kandang_source",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("farms", "0020_kandang_flock_source_ids"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="feed_initial_balance_iot",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="feed_initial_balance_accuracy",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cycle",
|
||||
name="feed_initial_balance_sync_error",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -68,12 +68,24 @@ class Kandang(models.Model):
|
||||
kandang_name = models.CharField(max_length=30)
|
||||
feed_in_button_urls = models.JSONField(default=list, blank=True)
|
||||
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
|
||||
source_kandang_id = models.BigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="kandang_id on the farm-location dashboard-cpsp deployment",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "kandang"
|
||||
ordering = ["kandang_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["site", "source_kandang_id"],
|
||||
condition=models.Q(source_kandang_id__isnull=False),
|
||||
name="uniq_kandang_site_source",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.kandang_name
|
||||
@@ -87,12 +99,24 @@ class Flock(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
related_name="flocks",
|
||||
)
|
||||
source_flock_id = models.BigIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="flock_id on the farm-location dashboard-cpsp deployment",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "flock"
|
||||
ordering = ["flock_name"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["kandang", "source_flock_id"],
|
||||
condition=models.Q(source_flock_id__isnull=False),
|
||||
name="uniq_flock_kandang_source",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.flock_name
|
||||
@@ -116,6 +140,9 @@ class Cycle(models.Model):
|
||||
doc_in_count = models.IntegerField()
|
||||
feed_initial_balance = models.IntegerField(default=0)
|
||||
feed_initial_balance_date = models.DateField(null=True, blank=True)
|
||||
feed_initial_balance_iot = models.IntegerField(null=True, blank=True)
|
||||
feed_initial_balance_accuracy = models.FloatField(null=True, blank=True)
|
||||
feed_initial_balance_sync_error = models.TextField(null=True, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
|
||||
)
|
||||
|
||||
@@ -38,10 +38,11 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"kandang_name",
|
||||
"feed_in_button_urls",
|
||||
"site",
|
||||
"source_kandang_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "source_kandang_id", "created_at", "updated_at"]
|
||||
|
||||
def validate_feed_in_button_urls(self, value):
|
||||
cleaned = []
|
||||
@@ -77,8 +78,15 @@ class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Flock
|
||||
fields = ["id", "flock_name", "kandang", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
fields = [
|
||||
"id",
|
||||
"flock_name",
|
||||
"kandang",
|
||||
"source_flock_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "source_flock_id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
@@ -105,6 +113,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"doc_in_count",
|
||||
"feed_initial_balance",
|
||||
"feed_initial_balance_date",
|
||||
"feed_initial_balance_iot",
|
||||
"feed_initial_balance_accuracy",
|
||||
"feed_initial_balance_sync_error",
|
||||
"status",
|
||||
"active_site",
|
||||
"source_cycle_id",
|
||||
@@ -121,6 +132,9 @@ class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
|
||||
"kandang_name",
|
||||
"feed_initial_balance",
|
||||
"feed_initial_balance_date",
|
||||
"feed_initial_balance_iot",
|
||||
"feed_initial_balance_accuracy",
|
||||
"feed_initial_balance_sync_error",
|
||||
"status",
|
||||
"active_site",
|
||||
"source_cycle_id",
|
||||
|
||||
+18
-119
@@ -6,7 +6,6 @@ from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.farms.models import Cycle, Flock, Kandang, Site
|
||||
from apps.operations.models import FeedSacks, ManualInput
|
||||
from apps.sync.models import ActiveSite
|
||||
|
||||
|
||||
@@ -264,7 +263,7 @@ class FarmAPITests(TestCase):
|
||||
results = response.data["results"] if isinstance(response.data, dict) else response.data
|
||||
self.assertEqual(len(results), 2)
|
||||
|
||||
def test_set_initial_balance_does_not_create_feed_sack_row(self):
|
||||
def test_initial_balance_write_endpoints_removed(self):
|
||||
start = date.today()
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
@@ -275,123 +274,21 @@ class FarmAPITests(TestCase):
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
response = self.client.post(
|
||||
post = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
||||
{"date": start.isoformat(), "feed_in_manual": 120},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.feed_initial_balance, 120)
|
||||
self.assertFalse(FeedSacks.objects.filter(cycle=cycle).exists())
|
||||
self.assertFalse(ManualInput.objects.filter(cycle=cycle).exists())
|
||||
|
||||
def test_set_initial_balance_does_not_change_existing_feed_sack_totals(self):
|
||||
start = date.today()
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
feed_row = FeedSacks.objects.create(
|
||||
cycle=cycle,
|
||||
date=start + timedelta(days=1),
|
||||
in_today=4,
|
||||
in_total=4,
|
||||
out_today=0,
|
||||
out_total=0,
|
||||
feed_use_today=0,
|
||||
feed_use_total=0,
|
||||
compare = self.client.get(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
|
||||
{"date": start.isoformat(), "manual": 120},
|
||||
)
|
||||
self.assertEqual(post.status_code, 404)
|
||||
self.assertEqual(compare.status_code, 404)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
||||
{"date": start.isoformat(), "feed_in_manual": 120},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
|
||||
feed_row.refresh_from_db()
|
||||
self.assertEqual(feed_row.in_total, 4)
|
||||
|
||||
def test_set_initial_balance_twice_on_different_dates_keeps_daily_totals(self):
|
||||
def test_cycle_api_exposes_mirrored_initial_balance_iot_fields(self):
|
||||
start = date(2026, 8, 26)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
feed_row = FeedSacks.objects.create(
|
||||
cycle=cycle,
|
||||
date=start + timedelta(days=2),
|
||||
in_today=6,
|
||||
in_total=6,
|
||||
out_today=0,
|
||||
out_total=0,
|
||||
feed_use_today=0,
|
||||
feed_use_total=0,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
first = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
||||
{"date": start.isoformat(), "feed_in_manual": 120},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(first.status_code, 200, first.data)
|
||||
second = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
||||
{"date": (start + timedelta(days=1)).isoformat(), "feed_in_manual": 80},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(second.status_code, 200, second.data)
|
||||
|
||||
cycle.refresh_from_db()
|
||||
self.assertEqual(cycle.feed_initial_balance, 80)
|
||||
self.assertEqual(cycle.feed_initial_balance_date, start + timedelta(days=1))
|
||||
self.assertEqual(FeedSacks.objects.filter(cycle=cycle).count(), 1)
|
||||
feed_row.refresh_from_db()
|
||||
self.assertEqual(feed_row.in_total, 6)
|
||||
|
||||
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(115, None))
|
||||
def test_set_initial_balance_compares_manual_against_combined_api_iot(self, mock_pull):
|
||||
start = date(2026, 8, 26)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
start_date=start,
|
||||
end_date=start + timedelta(days=34),
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
response = self.client.post(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance/",
|
||||
{"date": start.isoformat(), "feed_in_manual": 120},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["iot"], 115)
|
||||
self.assertEqual(response.data["accuracy"], 95.8)
|
||||
mock_pull.assert_called_once_with(cycle, start)
|
||||
|
||||
@patch("apps.farms.views.pull_initial_balance_iot", return_value=(112, None))
|
||||
def test_compare_initial_balance_returns_iot_and_accuracy(self, mock_pull):
|
||||
start = date(2026, 8, 27)
|
||||
cycle = Cycle.objects.create(
|
||||
kandang=self.kandang,
|
||||
total_days=35,
|
||||
@@ -400,15 +297,17 @@ class FarmAPITests(TestCase):
|
||||
doc_in_weight=40,
|
||||
doc_in_count=9000,
|
||||
feed_initial_balance=120,
|
||||
feed_initial_balance_date=start,
|
||||
feed_initial_balance_iot=115,
|
||||
feed_initial_balance_accuracy=95.8,
|
||||
feed_initial_balance_sync_error=None,
|
||||
status=Cycle.STATUS_ACTIVE,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/v1/cycles/{cycle.pk}/initial-balance-compare/",
|
||||
{"date": start.isoformat(), "manual": 120},
|
||||
)
|
||||
response = self.client.get(f"/api/v1/cycles/{cycle.pk}/")
|
||||
self.assertEqual(response.status_code, 200, response.data)
|
||||
self.assertEqual(response.data["iot"], 112)
|
||||
self.assertEqual(response.data["accuracy"], 93.3)
|
||||
mock_pull.assert_called_once_with(cycle, start)
|
||||
self.assertEqual(response.data["feed_initial_balance"], 120)
|
||||
self.assertEqual(response.data["feed_initial_balance_date"], start.isoformat())
|
||||
self.assertEqual(response.data["feed_initial_balance_iot"], 115)
|
||||
self.assertEqual(response.data["feed_initial_balance_accuracy"], 95.8)
|
||||
self.assertIsNone(response.data["feed_initial_balance_sync_error"])
|
||||
+29
-135
@@ -15,14 +15,6 @@ from apps.accounts.scoping import (
|
||||
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.active_site_registry import ensure_hq_site_for_active_site
|
||||
from apps.sync.models import ActiveSite
|
||||
|
||||
@@ -61,11 +53,35 @@ def _upsert_mirror_cycle_from_ingest(active_site: ActiveSite, payload: dict) ->
|
||||
# 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={},
|
||||
)
|
||||
|
||||
source_kandang_raw = payload.get("source_kandang_id", payload.get("kandang_id"))
|
||||
source_kandang_id = None
|
||||
if source_kandang_raw not in (None, ""):
|
||||
try:
|
||||
source_kandang_id = int(source_kandang_raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("source_kandang_id must be an integer.") from exc
|
||||
|
||||
kandang = None
|
||||
if source_kandang_id is not None:
|
||||
kandang = Kandang.objects.filter(
|
||||
site=site, source_kandang_id=source_kandang_id
|
||||
).first()
|
||||
if kandang is None:
|
||||
kandang = Kandang.objects.create(
|
||||
site=site,
|
||||
kandang_name=kandang_name,
|
||||
source_kandang_id=source_kandang_id,
|
||||
)
|
||||
elif kandang.kandang_name != kandang_name:
|
||||
kandang.kandang_name = kandang_name
|
||||
kandang.save(update_fields=["kandang_name", "updated_at"])
|
||||
if kandang is None:
|
||||
kandang, _ = Kandang.objects.get_or_create(
|
||||
site=site,
|
||||
kandang_name=kandang_name,
|
||||
defaults={},
|
||||
)
|
||||
|
||||
cycle = Cycle.objects.filter(
|
||||
active_site=active_site, source_cycle_id=source_cycle_id
|
||||
@@ -266,125 +282,3 @@ class CycleViewSet(viewsets.ModelViewSet):
|
||||
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,
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user