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

View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.jobs"
label = "jobs"
verbose_name = "Jobs"
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,11 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
class Command(BaseCommand):
help = "Placeholder — advance_cycle_days no longer applicable (current_day removed from ERD)"
def handle(self, *args, **options):
self.stdout.write("current_day field removed; this command is a no-op now.")
@@ -0,0 +1,117 @@
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import ApiKey, User
def ensure_bootstrap_user(
*,
user_name: str,
password: str,
status: str,
is_superuser: bool,
force_password: bool,
stdout,
) -> User:
user, created = User.objects.get_or_create(
user_name=user_name,
defaults={
"status": status,
"is_superuser": is_superuser,
},
)
if not created and user.status != status:
stdout.write(
f"Updating {user_name!r} status {user.status!r} → {status!r}"
)
should_set_password = created or not user.has_usable_password() or force_password
if should_set_password:
user.set_password(password)
user.status = status
user.is_superuser = is_superuser
user.save()
action = "Created" if created else "Updated password for"
stdout.write(f"{action} {status} user {user_name}")
else:
user.status = status
user.is_superuser = is_superuser
user.save(update_fields=["status", "is_superuser", "updated_at"])
stdout.write(f"User {user_name} already exists (password unchanged)")
return user
class Command(BaseCommand):
help = (
"Create production login accounts only (no demo sites/cycles/data). "
"BOOTSTRAP_ADMIN_* creates the break-glass superuser (ops dashboards + "
"user management; hidden from user lists). Optional BOOTSTRAP_STAFF_* creates a GM."
)
def add_arguments(self, parser):
parser.add_argument(
"--force-password",
action="store_true",
help="Reset password even when the user already has one",
)
@transaction.atomic
def handle(self, *args, **options):
force_password = options["force_password"]
admin_user_name = (settings.BOOTSTRAP_ADMIN_USER or "admin").strip()
admin_password = settings.BOOTSTRAP_ADMIN_PASSWORD or ""
if not admin_password:
raise CommandError(
"BOOTSTRAP_ADMIN_PASSWORD is required. Set it in .env before running bootstrap_admin."
)
admin = ensure_bootstrap_user(
user_name=admin_user_name,
password=admin_password,
status=User.STATUS_SUPERUSER,
is_superuser=True,
force_password=force_password,
stdout=self.stdout,
)
staff_user_name = (settings.BOOTSTRAP_STAFF_USER or "").strip()
staff_password = settings.BOOTSTRAP_STAFF_PASSWORD or ""
if staff_user_name:
if staff_user_name == admin_user_name:
raise CommandError(
"BOOTSTRAP_STAFF_USER must differ from BOOTSTRAP_ADMIN_USER."
)
if not staff_password:
raise CommandError(
"BOOTSTRAP_STAFF_PASSWORD is required when BOOTSTRAP_STAFF_USER is set."
)
ensure_bootstrap_user(
user_name=staff_user_name,
password=staff_password,
status=User.STATUS_GM,
is_superuser=False,
force_password=force_password,
stdout=self.stdout,
)
bootstrap_key = settings.BOOTSTRAP_API_KEY
if bootstrap_key:
api_key, _ = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap_key[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap_key)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
accounts = admin_user_name
if staff_user_name:
accounts = f"{admin_user_name}, {staff_user_name}"
self.stdout.write(
self.style.SUCCESS(
f"Bootstrap OK: login={accounts} "
"(no demo farm data — use Pengaturan to add sites/kandang/cycles)"
)
)
@@ -0,0 +1,58 @@
from django.core.management.base import BaseCommand
from django.db.models import Sum
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, FeedSacks, KPI
from apps.operations.services.visibility import visible_through_date
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
def handle(self, *args, **options):
cutoff = visible_through_date()
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
def visible(model):
return model.objects.filter(cycle=cycle, date__lte=cutoff)
counting = visible(ChickenCounting).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = visible(FeedSacks).order_by("-date", "-pk").first()
latest_cc = visible(ChickenCounting).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = visible(ChickenWeight).order_by("-date").first()
latest_mi = visible(ManualInput).order_by("-date").first()
if not all([latest_cc, latest_cw, latest_fs, latest_mi]):
self.stdout.write(f"cycle={cycle.pk} skipped (missing source records)")
continue
kpi, _ = KPI.objects.update_or_create(
cycle=cycle,
date=latest_cc.date,
defaults={
"cc": latest_cc,
"cw": latest_cw,
"fs": latest_fs,
"manual": latest_mi,
"mortality_total": counting["mortality"] or 0,
"chicken_life": counting["life"] or cycle.doc_in_count,
"feed_total": feed_sack.feed_use_total if feed_sack else 0,
"chicken_life_percentage": (
((counting["life"] or cycle.doc_in_count) / cycle.doc_in_count * 100.0)
if cycle.doc_in_count
else 0.0
),
},
)
count += 1
self.stdout.write(f"KPI {kpi.pk} for cycle {cycle.pk}")
self.stdout.write(self.style.SUCCESS(f"Recomputed {count} KPI row(s)"))
@@ -0,0 +1,146 @@
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.accounts.models import ApiKey, User
from apps.farms.models import Site
from apps.sync.models import CitySite
# Every executive role + inactive, for local HQ demos.
SEED_USERS = (
{
# Break-glass: status=superuser (ops + Manajemen Akun). Hidden from user lists.
"user_name": "admin",
"password": "admin123",
"status": User.STATUS_SUPERUSER,
"is_superuser": True,
},
{
"user_name": "director",
"password": "director123",
"status": User.STATUS_DIRECTOR,
},
{
"user_name": "director_admin",
"password": "directoradmin123",
"status": User.STATUS_DIRECTOR_ADMIN,
},
{
"user_name": "buh",
"password": "buh123",
"status": User.STATUS_BUH,
},
{
"user_name": "bu_admin",
"password": "buadmin123",
"status": User.STATUS_BU_ADMIN,
},
{
"user_name": "gm",
"password": "gm123",
"status": User.STATUS_GM,
"managed_by": "buh",
},
{
"user_name": "gm_admin",
"password": "gmadmin123",
"status": User.STATUS_GM_ADMIN,
"managed_by": None, # not on BUH org chart; only GM requests/confirm
},
{
"user_name": "account_admin",
"password": "accountadmin123",
"status": User.STATUS_ACCOUNT_ADMIN,
},
)
class Command(BaseCommand):
help = (
"Seed all HQ executive role logins + API key, then Sukawarna Kandang 1 "
"and Kandang 2 as default closed cycles (local mirror demo data). "
"Removes legacy staff login if present."
)
def _ensure_user(self, spec: dict, by_name: dict[str, User]) -> User:
managed_by_name = spec.get("managed_by")
managed_by = by_name.get(managed_by_name) if managed_by_name else None
defaults = {
"status": spec["status"],
"is_superuser": spec.get("is_superuser", False),
}
if managed_by is not None:
defaults["managed_by"] = managed_by
user, created = User.objects.get_or_create(
user_name=spec["user_name"],
defaults=defaults,
)
user.status = spec["status"]
user.is_superuser = spec.get("is_superuser", False)
if managed_by is not None:
user.managed_by = managed_by
elif "managed_by" in spec:
user.managed_by = None
user.set_password(spec["password"])
user.save()
role = spec["status"]
if user.is_superuser:
role = f"{role}+superuser"
link = f", managed_by={managed_by.user_name}" if managed_by else ""
action = "Created" if created else "Updated"
self.stdout.write(
f"{action} user {spec['user_name']} / {spec['password']} ({role}{link})"
)
return user
def _remove_legacy_staff(self, gm: User) -> None:
staff = User.objects.filter(user_name="staff").first()
if not staff:
return
moved_sites = Site.objects.filter(user=staff).update(user=gm)
moved_cities = CitySite.objects.filter(registered_by=staff).update(registered_by=gm)
ApiKey.objects.filter(user=staff).delete()
staff.delete()
self.stdout.write(
self.style.WARNING(
f"Removed legacy staff login "
f"(reassigned sites={moved_sites}, city_sites={moved_cities} to gm)"
)
)
@transaction.atomic
def handle(self, *args, **options):
by_name: dict[str, User] = {}
for spec in SEED_USERS:
user = self._ensure_user(spec, by_name)
by_name[user.user_name] = user
admin = by_name["admin"]
gm = by_name["gm"]
self._remove_legacy_staff(gm)
bootstrap = settings.BOOTSTRAP_API_KEY
if bootstrap:
api_key, _raw_key = ApiKey.generate(admin, "bootstrap")
api_key.prefix = bootstrap[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
elif not ApiKey.objects.filter(user=admin).exists():
_api_key, raw_key = ApiKey.generate(admin, "seed")
self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}"))
call_command("seed_sukawarna_kandang4")
call_command("seed_sukawarna_kandang5")
call_command("seed_iot_10min")
self.stdout.write(
self.style.SUCCESS(
"Seed OK: all executive roles + Sukawarna cycles + IoT panels "
"(Sukawarna owned by gm)"
)
)
@@ -0,0 +1,239 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 2 Lantai 1 is seeded from real sensor snapshots pulled from
``dashboard.cpsp.id/api/iot/flocks/`` (cached in ``scripts/iot_panel_10min_extract.json``);
every other flock/floor is generated from that data with a deterministic per-house
jitter so each cycle day holds a full 00:00..23:50 run of 144 readings.
Run ``scripts/extract_iot_10min.py`` to refresh the cache against the live API.
"""
from __future__ import annotations
import json
import random
from datetime import date as date_cls
from datetime import datetime, time, timedelta
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from apps.farms.models import Cycle, Flock
SLOTS_PER_DAY = 144 # 24h * 6
CHILL_FACTOR_POINTS = (
(0, 8.0),
(1, 8.0),
(7, 7.0),
(14, 6.0),
(21, 4.5),
(28, 3.5),
(35, 3.5),
(42, 3.0),
)
def chicken_chill_factor(age: float) -> float:
if age <= CHILL_FACTOR_POINTS[0][0]:
return CHILL_FACTOR_POINTS[0][1]
for idx in range(1, len(CHILL_FACTOR_POINTS)):
prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1]
next_age, next_factor = CHILL_FACTOR_POINTS[idx]
if age > next_age:
continue
span = next_age - prev_age
if span <= 0:
return next_factor
progress = (age - prev_age) / span
return prev_factor + (next_factor - prev_factor) * progress
return CHILL_FACTOR_POINTS[-1][1]
def experience_temperature(
*, average_temperature: float, wind_speed: float, humidity_pct: float, age_days: float
) -> float:
chill_factor = chicken_chill_factor(age_days)
rh_adjustment = (humidity_pct - 70.0) / 5.0
chill_effect = wind_speed * chill_factor - rh_adjustment
return average_temperature - chill_effect
def lerp(a: float, b: float, frac: float) -> float:
return a + (b - a) * frac
def slot_timestamp(day: date_cls, slot: int) -> datetime:
naive = datetime.combine(day, time(0, 0)) + timedelta(minutes=10 * slot)
return timezone.make_aware(naive, timezone.get_current_timezone())
def gap_fill_day(day_rows: dict[int, dict]) -> dict[int, dict]:
"""Return 144 slots for a day, interpolating any missing 10-minute windows."""
present = sorted(day_rows.items())
filled: dict[int, dict] = {}
for slot in range(SLOTS_PER_DAY):
if slot in day_rows:
filled[slot] = dict(day_rows[slot])
continue
prev = next = None
for s, row in present:
if s < slot:
prev = (s, row)
elif s > slot and next is None:
next = (s, row)
break
if prev is None and next is not None:
filled[slot] = dict(next[1])
elif next is None and prev is not None:
filled[slot] = dict(prev[1])
elif prev is not None and next is not None:
ps, prow = prev
ns, nrow = next
frac = (slot - ps) / (ns - ps)
filled[slot] = {
"wind_speed": lerp(prow["wind_speed"], nrow["wind_speed"], frac),
"humidity": lerp(prow["humidity"], nrow["humidity"], frac),
"water_total": lerp(prow["water_total"], nrow["water_total"], frac),
"average_temperature": lerp(
prow["average_temperature"], nrow["average_temperature"], frac
),
}
return filled
class Command(BaseCommand):
help = "Seed 10-minute IoT panel readings (144/day) for all flocks"
def add_arguments(self, parser):
parser.add_argument("--iot-json", default="")
parser.add_argument("--cycle-start", default="2026-05-22")
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
def load_real_rows(self, path: Path) -> dict[str, dict[int, dict]]:
if not path.is_file():
raise CommandError(f"IoT 10-min extract not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
by_date: dict[str, dict[int, dict]] = {}
for row in data["rows"]:
ts = datetime.fromisoformat(row["timestamp"])
slot = (ts.hour * 60 + ts.minute) // 10
by_date.setdefault(row["date"], {})[slot] = row
return by_date
@transaction.atomic
def handle(self, *args, **options):
iot_json = options["iot_json"] or str(
Path(settings.BASE_DIR).resolve().parent / "scripts" / "iot_panel_10min_extract.json"
)
cycle_start = date_cls.fromisoformat(options["cycle_start"])
real_by_date = self.load_real_rows(Path(iot_json))
cycles = Cycle.objects.filter(
start_date=cycle_start, kandang__flocks__isnull=False
).distinct()
if not cycles:
raise CommandError(f"No cycles starting {cycle_start} with flocks")
seeded = 0
for cycle in cycles:
kandang_name = cycle.kandang.kandang_name
for flock in cycle.kandang.flocks.order_by("flock_id"):
profile = self.profile_for(kandang_name, flock.flock_name)
rng = random.Random(options["jitter_seed"] + flock.pk)
rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng)
if not options["no_reset"]:
flock.iot_panels.all().delete()
for chunk in range(0, len(rows), 500):
flock.iot_panels.bulk_create(rows[chunk : chunk + 500])
seeded += len(rows)
self.stdout.write(
f" {kandang_name} / {flock.flock_name}: {len(rows)} readings"
)
self.stdout.write(self.style.SUCCESS(f"Seeded {seeded} IoT panel readings"))
def profile_for(self, kandang_name: str, flock_name: str) -> dict:
"""Per-house transform applied to the real Kandang 2 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 2":
if is_lantai1:
return {"real": True}
return {
"wind_delta": -0.05,
"humidity_delta": 1.2,
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 1: deterministic jitter (same approach as seed_sukawarna_kandang4).
return {
"temperature_jitter": (-0.4, 0.4),
"humidity_jitter": (-3.0, 3.0),
"wind_jitter": (-0.1, 0.1),
"water_factor": 0.9,
"water_jitter_abs": (-120.0, 120.0),
"humidity_delta": 1.2 if not is_lantai1 else 0.0,
"temperature_delta": -0.3 if not is_lantai1 else 0.0,
}
def build_flock_rows(self, flock: Flock, cycle: Cycle, real: dict, profile: dict, rng: random.Random) -> list:
from apps.operations.models import IotPanel
day = cycle.start_date
rows: list[IotPanel] = []
while day <= cycle.end_date:
iso = day.isoformat()
age_days = (day - cycle.start_date).days + 1
if profile.get("real"):
source = gap_fill_day(real.get(iso, {}))
else:
source = gap_fill_day(real.get(iso, {}))
if not source:
day += timedelta(days=1)
continue
real_total = source.get(SLOTS_PER_DAY - 1, {}).get("water_total", 0.0) or 0.0
target_total = real_total * profile.get("water_factor", 1.0)
if profile.get("water_jitter_abs"):
target_total += rng.uniform(*profile["water_jitter_abs"])
target_total = max(0.0, target_total)
water_scale = target_total / real_total if real_total else 0.0
for slot in range(SLOTS_PER_DAY):
base = source[slot]
avg_temp = base.get("average_temperature", 0.0) + profile.get("temperature_delta", 0.0)
if profile.get("temperature_jitter"):
avg_temp += rng.uniform(*profile["temperature_jitter"])
humidity = base.get("humidity", 0.0) + profile.get("humidity_delta", 0.0)
if profile.get("humidity_jitter"):
humidity += rng.uniform(*profile["humidity_jitter"])
humidity = min(100.0, max(0.0, humidity))
wind = base.get("wind_speed", 0.0) + profile.get("wind_delta", 0.0)
if profile.get("wind_jitter"):
wind += rng.uniform(*profile["wind_jitter"])
wind = max(0.0, wind)
water = base.get("water_total", 0.0) * water_scale
exp_temp = experience_temperature(
average_temperature=avg_temp,
wind_speed=wind,
humidity_pct=humidity,
age_days=age_days,
)
rows.append(
IotPanel(
flock=flock,
date=day,
timestamp=slot_timestamp(day, slot),
wind_speed=round(wind, 2),
humidity=round(humidity, 1),
water_total=round(water, 1),
average_temperature=round(avg_temp, 2),
experience_temperature=round(exp_temp, 2),
)
)
day += timedelta(days=1)
return rows
@@ -0,0 +1,435 @@
"""Seed Sukawarna / Kandang 1 closed cycle.
Kandang 1 is a generated dataset modeled on the Kandang 2 seed
(``seed_sukawarna_kandang5``). The two houses run the same 49-day window at
the same site, but Kandang 1 is stocked with 45,000 DOC and performs slightly
better (lower mortality, marginally better FCR / EEF).
Every daily series is hand-tuned so the numbers stay internally consistent:
* ``chicken_counting`` – daily mortality sums to the cumulative total; camera
count tracks live population (DOC - mortality - harvest) with coverage noise.
* ``chicken_weight`` – smooth growth curve; weighing continues through
close-out (day 38-48), flock average declines as heavy birds are harvested.
* ``feed_sacks`` – daily use ramps up to ~98 sacks and falls off during
close-out; total use (1,941 sacks) matches close-out FCR ≈ 1.71.
* ``harvest`` – 20 partial harvests day 29-48, 40,700 birds,
57.47 t live weight, avg ~1,412 g/bird at ~33.1 days; tail harvests leave
fewer than 50 birds in the house at close-out.
* ``kpi`` / ``manual`` – FCR/EEF/lot-weight follow the same formulas as the
Kandang 2 preview:
FCR = feed_total_kg / (harvest_weight_total_g + lot_weight_g)
EEF = life_pct * (avg_harvest_weight/1000) / (FCR * avg_harvest_day) * 100
lot = chicken_life * average_weight
* ``iot_panel`` – derived from the real Kandang 2 sensor extract with
deterministic per-house jitter (same site, same season).
"""
from __future__ import annotations
import json
import math
import random
from datetime import date, timedelta
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import IOT_EXTRACT_JSON
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
START_DATE = date(2026, 5, 22)
END_DATE = date(2026, 7, 9)
TOTAL_DAYS = 49
DOC_IN_WEIGHT = 35
DOC_IN_COUNT = 45_000
FLOCK_NAMES = ("Lantai 1", "Lantai 2")
# --- Daily mortality per age 0..48 (sums to 4,269 ≈ 9.49 % deplesi) ---------
MORTALITY = [
42, 58, 74, 78, 68, 62, 60,
52, 54, 47, 50, 44, 46, 48,
42, 45, 47, 50, 52, 48, 46,
44, 47, 42, 50, 58, 65, 72,
95, 120, 150, 175, 200, 185, 215,
240, 255, 235, 220, 140, 115, 100,
85, 60, 65, 50, 45, 28, 0,
]
# --- Flock average weight (g) per age 0..48 ---------------------------------
# Weighing continues through close-out (day 38-48). As heavy birds are
# harvested first, the remaining flock average weight declines.
AVG_WEIGHT = [
35, 47, 62, 80, 100, 123, 148,
175, 204, 235, 268, 303, 340, 379,
420, 463, 508, 555, 604, 655, 708,
763, 820, 879, 940, 1003, 1068, 1135,
1204, 1275, 1348, 1423, 1500, 1570, 1530,
1470, 1420, 1360, 1310, 1270, 1230, 1220,
1200, 1180, 1160, 1140, 1120, 1100, 1080,
]
# --- Weigh-sample size per age 0..48 (declines with live flock during close-out) ---
SAMPLE_COUNT = [
229, 214, 250, 230, 260, 240, 220,
280, 210, 250, 230, 260, 240, 220,
270, 250, 230, 260, 240, 220, 280,
250, 230, 270, 240, 220, 260, 240,
280, 250, 230, 270, 250, 230, 260,
240, 220, 250, 180, 150, 120, 100,
80, 60, 50, 40, 30, 20, 15,
]
# --- Uniformity (%) per age 0..48 --------------------------------------------
UNIFORMITY = [
0, 45, 62, 70, 66, 74, 71,
78, 69, 73, 81, 68, 75, 72,
70, 77, 74, 69, 72, 76, 71,
73, 68, 74, 70, 72, 66, 69,
73, 67, 71, 69, 72, 68, 65,
70, 67, 63, 62, 61, 60, 59,
58, 57, 56, 55, 54, 53, 52,
]
# --- Feed used (sacks, 50 kg each) per age 0..48; sums to 1,941 --------------
FEED_TODAY = [
24, 11, 14, 22, 24, 27, 31,
34, 38, 40, 42, 36, 38, 41,
44, 48, 59, 66, 74, 85, 89,
90, 57, 98, 92, 84, 72, 79,
80, 71, 71, 65, 57, 24, 29,
14, 13, 12, 8, 3, 3, 5, 8, 0, 8, 2, 4, 4, 1,
]
# --- Feed delivered (sacks) on delivery days (age -> sacks) ------------------
# Cumulative intake (1,910 sacks) ends just below usage, a small stock draw.
FEED_IN = {
1: 120, 4: 130, 7: 140, 10: 150, 13: 160, 16: 170, 19: 150,
22: 140, 25: 130, 28: 120, 31: 110, 34: 100, 37: 90, 40: 80, 43: 120,
}
# --- Karung keluar (out) per age 0..48: only during the final week, small ---
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [3, 4, 5, 3, 2, 2, 1]
# --- Harvest plan: age -> (birds, avg live weight g/bird) --------------------
# 20 partial harvests day 29-48; the tail harvests clear out the house so the
# leftover flock (chicken_life) at close-out stays under 50 birds.
HARVEST = {
29: (4_500, 1_310),
30: (5_000, 1_360),
31: (5_000, 1_430),
32: (5_000, 1_490),
33: (5_000, 1_540),
34: (4_500, 1_500),
35: (3_500, 1_440),
36: (2_500, 1_390),
37: (2_000, 1_330),
38: (1_500, 1_280),
39: (1_000, 1_240),
40: (500, 1_200),
41: (120, 1_190),
42: (100, 1_170),
43: (90, 1_150),
44: (80, 1_130),
45: (90, 1_110),
46: (80, 1_090),
47: (80, 1_070),
48: (60, 1_050),
}
def _round(value: float, ndigits: int = 2) -> float:
if math.isclose(value, 0.0, abs_tol=1e-12):
return 0.0
return round(value, ndigits)
def _site_owner() -> User:
gm = User.objects.filter(user_name="gm", status=User.STATUS_GM).first()
if gm is None:
raise CommandError(
"User 'gm' is required. Run `python manage.py seed_demo` first."
)
return gm
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 1 closed cycle (generated, modeled on Kandang 2)"
def add_arguments(self, parser):
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
@transaction.atomic
def handle(self, *args, **options):
iot_path = Path(options["iot_json"])
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
iot = json.loads(iot_path.read_text(encoding="utf-8"))
rng = random.Random(options["jitter_seed"])
user = _site_owner()
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
self.stdout.write(f"Sukawarna site owner: {user.user_name} ({user.status})")
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 1",
site=site,
)
existing = Cycle.objects.filter(kandang=kandang, start_date=START_DATE).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 1 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=START_DATE,
defaults={
"end_date": END_DATE,
"total_days": TOTAL_DAYS,
"doc_in_weight": DOC_IN_WEIGHT,
"doc_in_count": DOC_IN_COUNT,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = END_DATE
cycle.total_days = TOTAL_DAYS
cycle.doc_in_weight = DOC_IN_WEIGHT
cycle.doc_in_count = DOC_IN_COUNT
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES:
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
# --- cumulative series -------------------------------------------------
mort_total = 0
harvest_total = 0
harvest_weight_total = 0.0
feed_total = 0
feed_in_total = 0
out_total = 0
cc_by_date: dict[date, ChickenCounting] = {}
cw_by_date: dict[date, ChickenWeight] = {}
fs_by_date: dict[date, FeedSacks] = {}
manual_by_date: dict[date, ManualInput] = {}
harvest_day_accum = 0.0 # Σ (harvest × age)
harvest_wt_accum = 0.0 # Σ (harvest × batch avg weight)
for i, day in enumerate(dates):
age = i
live = DOC_IN_COUNT - mort_total - harvest_total
# --- chicken_counting -----------------------------------------
quality = "ok" if age < 34 else "atas_only"
coverage = 0.82 + 0.16 * math.sin(i / 1.9) - 0.011 * min(i, 36)
camera_count = max(1, int(round(live * max(coverage, 0.3))))
cc, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": camera_count,
"mortality_count": MORTALITY[i],
},
)
cc_by_date[day] = cc
# --- chicken_weight --------------------------------------------
weight = AVG_WEIGHT[i]
prev_weight = AVG_WEIGHT[i - 1] if i > 0 else DOC_IN_WEIGHT
adg = _round(weight - prev_weight, 2) if weight else 0.0
cw, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"doc_weight": DOC_IN_WEIGHT,
"average_weight": weight,
"chicken_count": SAMPLE_COUNT[i],
"uniformity": UNIFORMITY[i],
"average_daily_gain": adg,
},
)
cw_by_date[day] = cw
# --- feed_sacks ------------------------------------------------
feed_today = FEED_TODAY[i]
in_today = FEED_IN.get(age, 0)
out_today = OUT_TODAY[i]
feed_total += feed_today
feed_in_total += in_today
out_total += out_today
fs, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": feed_in_total,
"out_total": out_total,
"feed_use_today": feed_today,
"feed_use_total": feed_total,
},
)
fs_by_date[day] = fs
# --- harvest (manual) ------------------------------------------
harvest_today, harvest_wt_today = HARVEST.get(age, (0, 0))
if harvest_today:
harvest_total += harvest_today
harvest_weight_total += harvest_today * harvest_wt_today
harvest_day_accum += harvest_today * age
harvest_wt_accum += harvest_today * harvest_wt_today
mort_today = MORTALITY[i]
mort_total_after = mort_total + mort_today
life = DOC_IN_COUNT - mort_total_after - harvest_total
# Liveability for EEF: birds still accounted for (DOC minus mortality),
# not remaining in-house stock (which drops during harvest).
life_pct = (DOC_IN_COUNT - mort_total_after) / DOC_IN_COUNT * 100.0 if DOC_IN_COUNT else 0.0
# Running averages: flock weight before the first harvest, then the
# harvest-weighted running mean carried forward on idle days.
if harvest_today:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
elif harvest_total == 0:
avg_hday = float(age)
avg_hwt = float(weight) if weight else 0.0
else:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
lot_weight_g = life * weight if weight else 0.0
denom = harvest_weight_total + lot_weight_g
fcr = (feed_total * 50_000) / denom if denom > 0 else 0.0
if avg_hday > 0 and fcr > 0:
eef = life_pct * (avg_hwt / 1000.0) / (fcr * avg_hday) * 100.0
else:
eef = 0.0
manual, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": age,
"mortality_manual": mort_today,
"mortality_manual_total": mort_total_after,
"feed_in_manual": in_today,
"feed_in_manual_total": feed_in_total,
"feed_use_manual": feed_today,
"feed_use_manual_total": feed_total,
"feed_out_manual": out_today,
"feed_out_manual_total": out_total,
"harvest_manual": harvest_today,
"harvest_manual_total": harvest_total,
"harvest_weight_manual": float(harvest_today * harvest_wt_today),
"harvest_weight_manual_total": harvest_weight_total,
"manual_weight": weight,
"average_harvest_day_manual": _round(avg_hday, 4),
"average_harvest_weight_manual": _round(avg_hwt, 4),
"fcr_manual": _round(fcr, 4),
"eef_manual": _round(eef, 4),
},
)
manual_by_date[day] = manual
# --- KPI ---------------------------------------------------------
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"mortality": mort_today,
"mortality_total": mort_total_after,
"feed": feed_today,
"feed_total": feed_total,
"harvest": harvest_today,
"harvest_total": harvest_total,
"harvest_weight": float(harvest_today * harvest_wt_today),
"harvest_weight_total": harvest_weight_total,
"chicken_life": life,
"chicken_life_percentage": _round(life_pct, 3),
"iot_weight": _round(lot_weight_g, 2),
"average_harvest_day": _round(avg_hday, 4),
"average_harvest_weight": _round(avg_hwt, 4),
"fcr": _round(fcr, 4),
"eef": _round(eef, 4),
"cc": cc,
"cw": cw,
"fs": fs,
"manual": manual,
},
)
mort_total = mort_total_after
# --- IoT panel: Kandang 2 sensors with per-house jitter -------------
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
avg_temp = float(row["average_temperature"]) + rng.uniform(-0.4, 0.4)
humidity = min(max(float(row["humidity"]) + rng.uniform(-3.0, 3.0), 0.0), 100.0)
wind = max(float(row["wind_speed"]) + rng.uniform(-0.1, 0.1), 0.0)
water = max(float(row["water_total"]) * 0.9 + rng.uniform(-120.0, 120.0), 0.0)
chill = float(row["chill_factor"])
exp_temp = avg_temp - (70.0 - humidity) / 5.0 - wind * chill
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": _round(wind, 2),
"humidity": _round(humidity, 1),
"water_total": _round(water, 1),
"average_temperature": _round(avg_temp, 2),
"experience_temperature": _round(exp_temp, 4),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
f"closeout_eef={last_kpi.eef if last_kpi else None}"
)
)
@@ -0,0 +1,293 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import (
FEED_REFERENCE_XLSX,
IOT_EXTRACT_JSON,
PREVIEW_XLSX,
as_number,
load_feed_reference,
load_preview,
)
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
# --- Karung keluar (out) per cycle day 0..48: only during the final week ----
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [4, 5, 4, 3, 2, 1, 1]
def _site_owner() -> User:
gm = User.objects.filter(user_name="gm", status=User.STATUS_GM).first()
if gm is None:
raise CommandError(
"User 'gm' is required. Run `python manage.py seed_demo` first."
)
return gm
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 2 closed cycle from preview xlsx + IoT extract"
def add_arguments(self, parser):
parser.add_argument("--xlsx", default=str(PREVIEW_XLSX))
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--feed-reference", default=str(FEED_REFERENCE_XLSX))
parser.add_argument("--no-reset", action="store_true")
@transaction.atomic
def handle(self, *args, **options):
xlsx = Path(options["xlsx"])
iot_path = Path(options["iot_json"])
feed_reference_path = Path(options["feed_reference"])
if not xlsx.is_file():
raise CommandError(f"Preview not found: {xlsx}")
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
sheets = load_preview(xlsx)
iot = json.loads(iot_path.read_text(encoding="utf-8"))
feed_reference = {}
if feed_reference_path.is_file():
feed_reference = load_feed_reference(feed_reference_path)
else:
self.stderr.write(f"Feed reference not found, continuing without it: {feed_reference_path}")
user = _site_owner()
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
self.stdout.write(f"Sukawarna site owner: {user.user_name} ({user.status})")
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 2",
site=site,
)
start_date = date(2026, 5, 22)
end_date = date(2026, 7, 9)
existing = Cycle.objects.filter(kandang=kandang, start_date=start_date).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(
flock__kandang=kandang,
date__gte=existing.start_date,
date__lte=existing.end_date,
).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 2 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=start_date,
defaults={
"end_date": end_date,
"total_days": 49,
"doc_in_weight": 35,
"doc_in_count": 50000,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = end_date
cycle.total_days = 49
cycle.doc_in_weight = 35
cycle.doc_in_count = 50000
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"):
flock, _ = Flock.objects.get_or_create(flock_name=name, kandang=kandang)
flocks_by_name[name] = flock
cc_by_date = {}
for row in sheets["chicken_counting"]:
obj, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"total_count": int(as_number(row.get("total_count"))),
"mortality_count": int(as_number(row.get("mortality_count"))),
},
)
cc_by_date[row["date"]] = obj
cw_by_date = {}
for row in sheets["chicken_weight"]:
obj, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"age": int(as_number(row.get("age"))),
"doc_weight": int(as_number(row.get("doc_weight"))),
"average_weight": float(as_number(row.get("average_weight"))),
"chicken_count": int(as_number(row.get("chicken_count"))),
"uniformity": float(as_number(row.get("uniformity"))),
"average_daily_gain": float(as_number(row.get("average_daily_gain"))),
},
)
cw_by_date[row["date"]] = obj
fs_by_date = {}
fs_in_total = 0
fs_use_total = 0
fs_out_total = 0
for row in sheets["feed_sacks"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
in_today = int(ref["iot_in"]) if ref else int(as_number(row.get("in_today")))
feed_use_today = (
int(ref["iot_use"]) if ref else int(as_number(row.get("out_today")))
)
out_today = OUT_TODAY[(day - start_date).days]
fs_in_total += in_today
fs_use_total += feed_use_today
fs_out_total += out_today
obj, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": fs_in_total,
"out_total": fs_out_total,
"feed_use_today": feed_use_today,
"feed_use_total": fs_use_total,
},
)
fs_by_date[day] = obj
manual_by_date = {}
manual_in_total = 0
manual_use_total = 0
manual_out_total = 0
for row in sheets["manual_input"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
feed_in_manual = int(ref["manual_in"]) if ref else (
int(fs_row.in_today) if (fs_row := fs_by_date.get(day)) else 0
)
feed_use_manual = int(ref["manual_use"]) if ref else int(as_number(row.get("feed_manual")))
feed_out_manual = OUT_TODAY[(day - start_date).days]
manual_in_total += feed_in_manual
manual_use_total += feed_use_manual
manual_out_total += feed_out_manual
obj, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": int(as_number(row.get("age_manual"))),
"mortality_manual": int(as_number(row.get("mortality_manual"))),
"mortality_manual_total": int(as_number(row.get("mortality_manual_total"))),
"feed_in_manual": feed_in_manual,
"feed_in_manual_total": manual_in_total,
"feed_use_manual": feed_use_manual,
"feed_use_manual_total": manual_use_total,
"feed_out_manual": feed_out_manual,
"feed_out_manual_total": manual_out_total,
"harvest_manual": int(as_number(row.get("harvest_manual"))),
"harvest_manual_total": int(as_number(row.get("harvest_manual_total"))),
"harvest_weight_manual": float(as_number(row.get("harvest_weight_manual"))),
"harvest_weight_manual_total": float(
as_number(row.get("harvest_weight_manual_total"))
),
"manual_weight": float(
as_number(row.get("manual_weight_float") or row.get("manual_weight"))
),
"average_harvest_day_manual": float(
as_number(row.get("average_harvest_day_manual"))
),
"average_harvest_weight_manual": float(
as_number(row.get("average_harvest_weight_manual"))
),
"fcr_manual": float(as_number(row.get("fcr_manual"))),
"eef_manual": float(as_number(row.get("eef_manual"))),
},
)
manual_by_date[day] = obj
missing = []
for row in sheets["kpi_preview"]:
day = row["date"]
try:
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": int(as_number(row.get("age"))),
"mortality": int(as_number(row.get("deplesi"))),
"mortality_total": int(as_number(row.get("deplesi_total"))),
"feed": int(as_number(row.get("feed_use_manual"))),
"feed_total": int(as_number(row.get("feed_use_total_manual"))),
"harvest": int(as_number(row.get("panen"))),
"harvest_total": int(as_number(row.get("panen_total"))),
"harvest_weight": float(as_number(row.get("harvest_weight_g"))),
"harvest_weight_total": float(as_number(row.get("harvest_weight_total_g"))),
"chicken_life": int(as_number(row.get("chicken_life"))),
"chicken_life_percentage": float(as_number(row.get("chicken_life_pct"))),
"iot_weight": float(as_number(row.get("lot_weight_g"))),
"average_harvest_day": float(as_number(row.get("avg_harvest_day"))),
"average_harvest_weight": float(as_number(row.get("avg_harvest_weight_g"))),
"fcr": float(as_number(row.get("fcr"))),
"eef": float(as_number(row.get("eef"))),
"cc": cc_by_date[day],
"cw": cw_by_date[day],
"fs": fs_by_date[day],
"manual": manual_by_date[day],
},
)
except KeyError:
missing.append(str(day))
if missing:
raise CommandError(f"KPI dates missing source rows: {missing}")
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": float(row["wind_speed"]),
"humidity": float(row["humidity"]),
"water_total": float(row["water_total"]),
"average_temperature": float(row["average_temperature"]),
"experience_temperature": float(row["experience_temperature"]),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(kandang=kandang).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
)
)
@@ -0,0 +1,45 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.chicken_counting_edge import (
ChickenCountingEdgeError,
sync_chicken_counting_for_cycle,
)
class Command(BaseCommand):
help = "Sync chicken counting + mortality IoT from the edge vision API"
def add_arguments(self, parser):
parser.add_argument("--cycle-id", type=int, default=None)
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:
self.stdout.write(
"CHICKEN_COUNTING_EDGE_*_SYNC_ENABLED=false; skipping"
)
return
cycle_id = options.get("cycle_id")
if cycle_id:
cycles = Cycle.objects.filter(pk=cycle_id).select_related("kandang")
else:
cycles = Cycle.objects.filter(status=Cycle.STATUS_ACTIVE).select_related(
"kandang"
)
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 '-'}"
)
)
@@ -0,0 +1,23 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.operations.services.iot_api import IotApiError, sync_iot_panels
class Command(BaseCommand):
help = "Sync IoT panel readings from the external IoT flocks API (10-minute snapshots)"
def handle(self, *args, **options):
if not settings.IOT_SYNC_ENABLED:
self.stdout.write("IOT_SYNC_ENABLED=false; skipping")
return
try:
summary = sync_iot_panels()
except IotApiError as exc:
self.stderr.write(str(exc))
return
self.stdout.write(
self.style.SUCCESS(
f"IoT sync OK: upserted={summary.upserted} skipped={summary.skipped} pages={summary.pages}"
)
)
@@ -0,0 +1,29 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.karung_web import KarungWebError, sync_karung_for_cycle
class Command(BaseCommand):
help = "Sync FeedSacks rows from karung-web-admin (today sections + combined history)"
def handle(self, *args, **options):
if not settings.KARUNG_WEB_ADMIN_SYNC_ENABLED:
self.stdout.write("KARUNG_WEB_ADMIN_SYNC_ENABLED=false; skipping")
return
synced = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
try:
feed_sack, summary = sync_karung_for_cycle(cycle, section="today")
synced += 1
self.stdout.write(
f"cycle={cycle.pk} in_today={feed_sack.in_today} "
f"out_today={feed_sack.out_today} date={summary.get('snapshot_date')} "
f"history={','.join(summary.get('history_dates') or [])}"
)
except KarungWebError as exc:
self.stderr.write(f"cycle={cycle.pk} error={exc}")
except Exception as exc: # noqa: BLE001
self.stderr.write(f"cycle={cycle.pk} unexpected={exc}")
self.stdout.write(self.style.SUCCESS(f"Synced {synced} feed sack row(s)"))
+104
View File
@@ -0,0 +1,104 @@
"""Load Sukawarna / Kandang 2 preview sheets into ORM-ready row dicts."""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
REPO_ROOT = Path(__file__).resolve().parents[3]
PREVIEW_XLSX = REPO_ROOT / "backup" / "kandang5_sukawarna_preview.xlsx"
IOT_EXTRACT_JSON = REPO_ROOT / "scripts" / "iot_panel_cycle_extract.json"
# Final-cycle report (reference for manual feed use on Kandang 2).
FEED_REFERENCE_XLSX = (
REPO_ROOT
/ "backup"
/ "Laporan_Akhir-Siklus_Sukawarna_Kandang-Atas_Akhir-Siklus_2026-05-22_sd_2026-07-09.xlsx"
)
def as_date(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if len(text) >= 10 and text[0].isdigit() and text[4] == "-":
return date.fromisoformat(text[:10])
return None
def as_number(value: Any, default: float | int = 0):
if value is None or value == "":
return default
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value
try:
if isinstance(value, str) and "." not in value:
return int(value)
return float(value)
except (TypeError, ValueError):
return default
def dated_rows(ws) -> list[dict[str, Any]]:
headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
rows: list[dict[str, Any]] = []
for raw in ws.iter_rows(min_row=2, values_only=True):
mapped = {str(headers[i]): raw[i] for i in range(len(headers))}
parsed = as_date(mapped.get("date"))
if parsed is None:
continue
mapped["date"] = parsed
rows.append(mapped)
return rows
def load_preview(path: Path | None = None) -> dict[str, list[dict[str, Any]]]:
workbook = load_workbook(path or PREVIEW_XLSX, data_only=True)
return {
"chicken_counting": dated_rows(workbook["chicken_counting"]),
"chicken_weight": dated_rows(workbook["chicken_weight"]),
"feed_sacks": dated_rows(workbook["feed_sacks"]),
"manual_input": dated_rows(workbook["manual_input"]),
"kpi_preview": dated_rows(workbook["kpi_preview"]),
}
def load_feed_reference(path: Path | None = None) -> dict[str, dict[str, int]]:
"""Load the final-cycle report's Feed In / Feed Use sheets.
Returns ``{date: {manual_in, iot_in, manual_use, iot_use}}``. These values are
the authoritative reference for the karung "Manual" / "IOT" columns.
"""
workbook = load_workbook(path or FEED_REFERENCE_XLSX, data_only=True)
feed_in: dict[str, dict[str, int]] = {}
for raw in workbook["Feed In"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_in[parsed.isoformat()] = {
"manual_in": int(as_number(raw[1])),
"iot_in": int(as_number(raw[2])),
}
feed_use: dict[str, dict[str, int]] = {}
for raw in workbook["Feed Use"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_use[parsed.isoformat()] = {
"manual_use": int(as_number(raw[2])),
"iot_use": int(as_number(raw[3])),
}
merged: dict[str, dict[str, int]] = {}
for key in feed_in:
if key in feed_use:
merged[key] = {**feed_in[key], **feed_use[key]}
return merged
+55
View File
@@ -0,0 +1,55 @@
from django.core.management.base import CommandError
from django.test import TestCase, override_settings
from apps.accounts.models import User
from apps.farms.models import Cycle, Site
from django.core.management import call_command
@override_settings(
BOOTSTRAP_ADMIN_USER="admin",
BOOTSTRAP_ADMIN_PASSWORD="Pr04dm1n",
BOOTSTRAP_STAFF_USER="",
BOOTSTRAP_STAFF_PASSWORD="",
)
class BootstrapAdminTests(TestCase):
def test_creates_break_glass_superuser_without_demo_data(self):
call_command("bootstrap_admin")
user = User.objects.get(user_name="admin")
self.assertEqual(user.status, User.STATUS_SUPERUSER)
self.assertTrue(user.is_superuser)
self.assertTrue(user.check_password("Pr04dm1n"))
self.assertFalse(Site.objects.exists())
self.assertFalse(Cycle.objects.exists())
def test_requires_password(self):
with self.settings(BOOTSTRAP_ADMIN_PASSWORD=""):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_creates_gm_when_configured(self):
call_command("bootstrap_admin")
staff = User.objects.get(user_name="staff")
self.assertEqual(staff.status, User.STATUS_GM)
self.assertFalse(staff.is_superuser)
self.assertTrue(staff.check_password("St4ff!"))
@override_settings(
BOOTSTRAP_STAFF_USER="staff",
BOOTSTRAP_STAFF_PASSWORD="",
)
def test_requires_staff_password_when_user_set(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")
@override_settings(
BOOTSTRAP_STAFF_USER="admin",
BOOTSTRAP_STAFF_PASSWORD="St4ff!",
)
def test_rejects_staff_same_as_admin(self):
with self.assertRaises(CommandError):
call_command("bootstrap_admin")