first commit

This commit is contained in:
Alberto-Audrix committed 2026-08-26 13:46:05 +07:00
commit 367533c02b
171 files changed
+101175

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Empty uses the Vite proxy (/api → Django :8000).
# Set a full origin only if you skip the proxy.
VITE_API_BASE=/api/v1
+42
View File
@@ -0,0 +1,42 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"plugins": ["@typescript-eslint", "react", "react-hooks", "prettier"],
"rules": {
"prettier/prettier": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/static-components": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
"react/no-unescaped-entities": "off"
},
"settings": {
"react": {
"version": "detect"
}
},
"ignorePatterns": ["dist", "node_modules", "*.config.js", "*.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
dist-ssr
*.local
.env
coverage
.vite
backup
__pycache__
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
dist-ssr
coverage
.vite
backend/.venv
backend/.pytest_cache
backend/**/node_modules
+9
View File
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5",
"arrowParens": "always",
"endOfLine": "lf"
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import AppContent from './AppContent.tsx';
import { AuthProvider, useAuth } from './context/AuthContext.tsx';
import { FarmProvider } from './context/FarmContext.tsx';
import AuthPage from './components/auth/AuthPage.tsx';
import { Spinner } from './components/ui/Feedback.tsx';
const Gate: React.FC = () => {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Spinner />
</div>
);
}
if (!user) return <AuthPage />;
return (
<FarmProvider>
<AppContent />
</FarmProvider>
);
};
const App: React.FC = () => (
<AuthProvider>
<Gate />
</AuthProvider>
);
export default App;
+105
View File
@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import type { View } from './types/common.ts';
import { useFarm } from './context/FarmContext.tsx';
import Sidebar from './components/Sidebar.tsx';
import Header from './components/Header.tsx';
import CycleTimeline from './components/CycleTimeline.tsx';
import Dashboard from './components/Dashboard.tsx';
import DataManagementPage from './components/counting/DataManagementPage.tsx';
import WeightMonitoringPage from './components/WeightMonitoringPage.tsx';
import WeightStandardPage from './components/WeightStandardPage.tsx';
import FeedSackCountingPage from './components/FeedSackCountingPage.tsx';
import CobbStandardPage from './components/CobbStandardPage.tsx';
import IotPanelPage from './components/IotPanelPage.tsx';
import FcrDashboardPage from './components/FcrDashboardPage.tsx';
import EefDashboardPage from './components/EefDashboardPage.tsx';
import SettingsPage from './components/SettingsPage.tsx';
import { Banner } from './components/ui/Feedback.tsx';
const AppContent: React.FC = () => {
const { error, loading } = useFarm();
const [view, setView] = useState<View>('dashboard');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isTimelineVisible, setIsTimelineVisible] = useState(false);
const goSettings = () => setView('kandangSettings');
const renderView = () => {
switch (view) {
case 'dashboard':
return <Dashboard setView={setView} />;
case 'dataManagement':
return <DataManagementPage onSettings={goSettings} />;
case 'weightMonitoring':
return <WeightMonitoringPage onSettings={goSettings} />;
case 'weightStandard':
return <WeightStandardPage />;
case 'feedSackCounting':
return <FeedSackCountingPage onSettings={goSettings} />;
case 'cobbStandard':
return <CobbStandardPage />;
case 'iotPanel':
return <IotPanelPage onSettings={goSettings} />;
case 'fcrDashboard':
return <FcrDashboardPage onSettings={goSettings} />;
case 'eefDashboard':
return <EefDashboardPage onSettings={goSettings} />;
case 'kandangSettings':
return <SettingsPage />;
default:
return <Dashboard setView={setView} />;
}
};
return (
<div className="flex h-screen bg-gray-100">
<Sidebar
isCollapsed={isSidebarCollapsed}
view={view}
setView={setView}
onToggleCollapse={() => setIsSidebarCollapsed((v) => !v)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<Header
onToggleSidebar={() => setIsSidebarCollapsed((v) => !v)}
isTimelineVisible={isTimelineVisible}
onToggleTimeline={() => setIsTimelineVisible((v) => !v)}
/>
<div className="flex-1 overflow-x-hidden overflow-y-auto">
{isTimelineVisible && (
<div className="px-4 sm:px-6 lg:px-8 pt-4 sm:pt-6 lg:pt-8">
<CycleTimeline />
</div>
)}
<div
className={
isTimelineVisible
? 'px-4 sm:px-6 lg:px-8 pb-8'
: 'px-4 sm:px-6 lg:px-8 pt-4 sm:pt-6 lg:pt-8 pb-8'
}
>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{renderView()}
</div>
</div>
</div>
{loading && (
<div className="fixed inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-8 shadow-2xl flex flex-col items-center">
<div className="relative w-16 h-16 mb-4">
<div className="absolute inset-0 border-4 border-gray-200 rounded-full"></div>
<div className="absolute inset-0 border-4 border-red-600 rounded-full border-t-transparent animate-spin"></div>
</div>
<p className="text-gray-700 font-semibold text-lg">Memuat Data...</p>
<p className="text-gray-500 text-sm mt-2">Mohon tunggu sebentar</p>
</div>
</div>
)}
</div>
);
};
export default AppContent;
+94
View File
@@ -0,0 +1,94 @@
# dashboard-new-v2
Poultry dashboard SPA. Looks like the legacy `dashboard/` app and talks to the Django API in `backend/` (`/api/v1/`).
## Project layout
```
dashboard-new-v2/
backend/ ← Django API (manage.py, apps, migrations)
components/ ← pages and UI
services/ ← API client
...
```
## Prerequisites
- Node 20+
- Python 3.11+
## First-time setup
**Backend** (terminal 1):
Install the Python dependencies once — only needed if Django/DRF aren't already
in your Python environment (they're listed in `backend/requirements.txt`):
```bash
cd dashboard-new-v2/backend
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
```
Then set up the database and seed data:
```bash
cd dashboard-new-v2/backend
python manage.py migrate
python manage.py seed_demo
# or via the npm wrapper: npm run seed
```
Seed logins:
- `admin` / `admin123` — superadmin (can create/manage sites and kandang)
- `staff` / `staff123` — active staff (owns the seeded site; can read sites/kandang and create cycles, but cannot create/edit/delete sites or kandang)
`seed_demo` creates both logins plus an API key, then seeds the default Sukawarna demo data: **Kandang 4** (45,000 DOC) and **Kandang 5** (50,000 DOC), both 49-day closed cycles (22 May – 9 Jul 2026) with full daily data (chicken counting, chicken weight, feed sacks, manual input, KPI) and **10-minute IoT panel readings** (144 readings per day per flock). Feed-sack `out` (keluar) data only occurs in the final week of the cycle (~20 sacks total), separate from usage (`feed_use`).
**Frontend** (terminal 2):
```bash
cd dashboard-new-v2
cp .env.example .env
npm install
```
## Run (two terminals, like old dashboard)
**Terminal 1 — API:**
```bash
cd dashboard-new-v2/backend
npm run dev
# `node dev.mjs` starts Django runserver :8000, preferring the .venv python if
# present, otherwise the global python (so no pip install is needed when the
# deps are already installed). Or directly: python manage.py runserver 0.0.0.0:8000
```
**Terminal 2 — UI:**
```bash
cd dashboard-new-v2
npm run dev
```
Open [http://localhost:3001](http://localhost:3001). Vite proxies `/api` to Django on `:8000`.
## Screens
Login, Dashboard (KPI + AI insight + Total Pakan section + in-memory Harian/Akhir Siklus report), Hitung Ayam, Berat Ayam, Standar Ross, Hitung Karung (Sync hari ini + Saldo Awal + daily in/use/out tables), Standar Performa, Panel IoT (per-day 10-minute readings with charts), FCR, EEF, Pengaturan.
Reports are generated in the browser from live `/api/v1/` data and exportable as **PDF** (multi-page A4, block-aware page breaks, compressed JPEG) and **Excel** (Chicken Counting, Weight, Feed In, Feed Use, Feed Out, FCR, EEF sheets with IoT-vs-manual accuracy). Closing the overlay drops the result; nothing is saved on Django.
The **Saldo Awal** feature on Hitung Karung posts `feed_initial_balance` to the cycle and compares the manual feed-in against the IoT-detected "in" for that date with an accuracy figure.
Karung Sync calls karung-web-admin. If that host is down the API often returns 502; stored rows still render.
## Tests
```bash
npm run test:run
```
+11
View File
@@ -0,0 +1,11 @@
__pycache__
*.py[cod]
.env
.venv
venv
db.sqlite3
data/
media/
staticfiles/
*.egg-info
.git
+38
View File
@@ -0,0 +1,38 @@
DEBUG=True
SECRET_KEY=change-me-in-production
ALLOWED_HOSTS=localhost,127.0.0.1,api
CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000,http://localhost:3001,http://127.0.0.1:3001
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001
CORS_ALLOW_CREDENTIALS=True
# SQLite (local)
DB_ENGINE=django.db.backends.sqlite3
DB_NAME=db.sqlite3
# Docker/compose typically overrides to:
# DB_NAME=/app/data/db.sqlite3
# PostgreSQL (production) — use with: docker compose --profile prod up
# DB_ENGINE=django.db.backends.postgresql
# DB_NAME=newdashboard
# DB_USER=newdashboard
# DB_PASSWORD=change-me
# DB_HOST=db
# DB_PORT=5432
WAGTAIL_SITE_NAME=New Dashboard
WAGTAILADMIN_BASE_URL=http://localhost:8000
API_PAGE_SIZE=50
SESSION_COOKIE_SECURE=False
CSRF_COOKIE_SECURE=False
# Optional: create hashed ApiKey on seed/bootstrap (raw shown once in seed output)
BOOTSTRAP_API_KEY=
# karung-web-admin (external)
KARUNG_WEB_ADMIN_BASE_URL=http://127.0.0.1:5000
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS=30
KARUNG_WEB_ADMIN_SYNC_ENABLED=false
CRON_ADVANCE_CYCLE_DAY=true
+10
View File
@@ -0,0 +1,10 @@
.venv/
__pycache__/
*.py[cod]
.env
db.sqlite3
data/
media/
staticfiles/
*.egg-info/
.Python
+61
View File
@@ -0,0 +1,61 @@
# New Dashboard Backend
Greenfield Django 5 + Wagtail 6 + DRF API aligned to `ERD_dashboard.png`.
## Services
| Compose service | Role |
| --------------- | ------------------------------------------------ |
| `api` | Gunicorn on `:8000` |
| `cron` | Same image, runs management commands on schedule |
| `db` | Postgres 16 (`--profile prod`) |
## Quick start (local, no Docker)
From `dashboard-new-v2/backend`:
```bash
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # if needed
python manage.py migrate
python manage.py seed_demo
npm run dev
# or: python manage.py runserver 0.0.0.0:8000
```
Seed creates user `admin` / `admin123`, then seeds the default Sukawarna demo data — **Kandang 4** and **Kandang 5** closed cycles (49 days each, 22 May – 9 Jul 2026) with full daily records and IoT panels. A manager login `skw_manager` / `skw_manager123` is created too. It also prints a one-time API key.
## Docker Compose
```bash
cp dashboard-new-v2/backend/.env.example dashboard-new-v2/backend/.env
# edit KARUNG_WEB_ADMIN_BASE_URL as needed
docker compose up --build
```
Production Postgres:
```bash
# set DB_ENGINE=django.db.backends.postgresql and DB_* in dashboard-new-v2/backend/.env
docker compose --profile prod up --build
```
## Auth
- **Session:** `POST /api/v1/auth/login/` then send session cookie + `X-CSRFToken` on writes
- **API key:** header `X-API-Key: <raw>` (hashed at rest)
- Public: `GET /api/v1/health/`
## External integrations
- karung-web-admin → `POST /api/v1/karungs/request/` (`KARUNG_WEB_ADMIN_BASE_URL`)
## Docs
- Curl samples: [docs/API_CURL.md](../../docs/API_CURL.md)
- Postman: [docs/postman/](../../docs/postman/)
- OpenAPI UI: `http://localhost:8000/api/docs/`
- Wagtail admin: `http://localhost:8000/admin/`
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+33
View File
@@ -0,0 +1,33 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from apps.accounts.models import ApiKey, User
@admin.register(User)
class UserAdmin(DjangoUserAdmin):
ordering = ("user_name",)
list_display = ("user_name", "status", "created_at")
search_fields = ("user_name",)
fieldsets = (
(None, {"fields": ("user_name", "password")}),
("Permissions", {"fields": ("status", "groups", "user_permissions")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("user_name", "password1", "password2", "status"),
},
),
)
filter_horizontal = ("groups", "user_permissions")
@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
list_display = ("name", "prefix", "user", "is_active", "last_used_at", "created_at")
list_filter = ("is_active",)
search_fields = ("name", "prefix", "user__user_name")
readonly_fields = ("prefix", "key_hash", "last_used_at", "created_at", "updated_at")
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
verbose_name = "Accounts"
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from django.utils.translation import gettext_lazy as _
from rest_framework import authentication, exceptions
from apps.accounts.models import ApiKey
class ApiKeyAuthentication(authentication.BaseAuthentication):
"""Authenticate via X-API-Key header (hashed lookup)."""
keyword = "X-API-Key"
def authenticate(self, request):
raw_key = request.headers.get(self.keyword) or request.META.get("HTTP_X_API_KEY")
if not raw_key:
return None
if len(raw_key) < 8:
raise exceptions.AuthenticationFailed(_("Invalid API key"))
prefix = raw_key[:8]
candidates = ApiKey.objects.select_related("user").filter(
prefix=prefix, is_active=True, user__is_active=True
)
for api_key in candidates:
if api_key.verify(raw_key):
api_key.touch()
return (api_key.user, api_key)
raise exceptions.AuthenticationFailed(_("Invalid API key"))
def authenticate_header(self, request):
return self.keyword
@@ -0,0 +1,55 @@
# Generated by Django 5.2.17 on 2026-08-10 08:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('user_name', models.CharField(max_length=30, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'db_table': 'user_access',
'ordering': ['user_name'],
},
),
migrations.CreateModel(
name='ApiKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('prefix', models.CharField(db_index=True, max_length=12)),
('key_hash', models.CharField(max_length=64)),
('is_active', models.BooleanField(default=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'api_keys',
'ordering': ['-created_at'],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="user",
old_name="id",
new_name="user_id",
),
]
@@ -0,0 +1,39 @@
from django.db import migrations, models
def backfill_user_status(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.is_superuser:
status = "superadmin"
flags = {"is_active": True, "is_staff": True, "is_superuser": True}
elif not user.is_active:
status = "inactive"
flags = {"is_active": False, "is_staff": False, "is_superuser": False}
else:
status = "active"
flags = {"is_active": True, "is_staff": False, "is_superuser": False}
User.objects.filter(pk=user.pk).update(status=status, **flags)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
]
operations = [
migrations.AddField(
model_name="user",
name="status",
field=models.CharField(
choices=[
("active", "Active"),
("inactive", "Inactive"),
("superadmin", "Super Admin"),
],
default="active",
max_length=30,
),
),
migrations.RunPython(backfill_user_status, migrations.RunPython.noop),
]
@@ -0,0 +1,29 @@
from django.db import migrations
def sync_status_flags(apps, schema_editor):
User = apps.get_model("accounts", "User")
for user in User.objects.all():
if user.status == "inactive":
user.is_active = False
user.is_staff = False
user.is_superuser = False
elif user.status == "superadmin":
user.is_active = True
user.is_staff = False
user.is_superuser = True
else:
user.is_active = True
user.is_superuser = False
user.save(update_fields=["is_active", "is_staff", "is_superuser", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_user_status"),
]
operations = [
migrations.RunPython(sync_status_flags, migrations.RunPython.noop),
]
Whitespace-only changes.
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import hashlib
import secrets
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone
class UserManager(BaseUserManager):
def create_user(self, user_name: str, password: str | None = None, **extra_fields):
if not user_name:
raise ValueError("user_name is required")
extra_fields.setdefault("status", User.STATUS_ACTIVE)
user = self.model(user_name=user_name, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, user_name: str, password: str | None = None, **extra_fields):
extra_fields["status"] = User.STATUS_SUPERADMIN
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
return self.create_user(user_name, password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin):
"""ERD User Access — username field is user_name."""
STATUS_ACTIVE = "active"
STATUS_INACTIVE = "inactive"
STATUS_SUPERADMIN = "superadmin"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_INACTIVE, "Inactive"),
(STATUS_SUPERADMIN, "Super Admin"),
]
user_id = models.BigAutoField(primary_key=True)
user_name = models.CharField(max_length=30, unique=True)
status = models.CharField(max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = "user_name"
REQUIRED_FIELDS: list[str] = []
class Meta:
db_table = "user_access"
ordering = ["user_name"]
def __str__(self) -> str:
return self.user_name
def save(self, *args, **kwargs):
if self.status == self.STATUS_INACTIVE:
self.is_active = False
self.is_staff = False
self.is_superuser = False
elif self.status == self.STATUS_SUPERADMIN:
self.is_active = True
self.is_staff = False
self.is_superuser = True
else:
self.is_active = True
self.is_superuser = False
# is_staff is left untouched for active users, so an operator can be
# explicitly flagged as staff without being a superadmin.
super().save(*args, **kwargs)
class ApiKey(models.Model):
"""Hashed API keys for X-API-Key authentication."""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="api_keys")
name = models.CharField(max_length=100)
prefix = models.CharField(max_length=12, db_index=True)
key_hash = models.CharField(max_length=64)
is_active = models.BooleanField(default=True)
last_used_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "api_keys"
ordering = ["-created_at"]
def __str__(self) -> str:
return f"{self.name} ({self.prefix}…)"
@staticmethod
def hash_key(raw_key: str) -> str:
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
@classmethod
def generate(cls, user: User, name: str) -> tuple[ApiKey, str]:
raw = secrets.token_urlsafe(32)
prefix = raw[:8]
instance = cls.objects.create(
user=user,
name=name,
prefix=prefix,
key_hash=cls.hash_key(raw),
)
return instance, raw
def verify(self, raw_key: str) -> bool:
return secrets.compare_digest(self.key_hash, self.hash_key(raw_key))
def touch(self) -> None:
self.last_used_at = timezone.now()
self.save(update_fields=["last_used_at", "updated_at"])
+45
View File
@@ -0,0 +1,45 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.accounts.models import ApiKey
User = get_user_model()
class PkAsIdMixin(serializers.Serializer):
"""Keep JSON `id` stable after ERD-named primary keys."""
id = serializers.IntegerField(source="pk", read_only=True)
class UserSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "user_name", "status", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class LoginSerializer(serializers.Serializer):
user_name = serializers.CharField(max_length=30)
password = serializers.CharField(write_only=True)
class ApiKeySerializer(serializers.ModelSerializer):
class Meta:
model = ApiKey
fields = [
"id",
"name",
"prefix",
"is_active",
"last_used_at",
"created_at",
"updated_at",
"user",
]
read_only_fields = fields
class ApiKeyCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
user_id = serializers.IntegerField(required=False)
+23
View File
@@ -0,0 +1,23 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.accounts.views import (
ApiKeyViewSet,
CsrfView,
LoginView,
LogoutView,
MeView,
UserViewSet,
)
router = DefaultRouter()
router.register("users", UserViewSet, basename="user")
router.register("api-keys", ApiKeyViewSet, basename="api-key")
urlpatterns = [
path("auth/csrf/", CsrfView.as_view(), name="auth-csrf"),
path("auth/login/", LoginView.as_view(), name="auth-login"),
path("auth/logout/", LogoutView.as_view(), name="auth-logout"),
path("auth/me/", MeView.as_view(), name="auth-me"),
*router.urls,
]
+117
View File
@@ -0,0 +1,117 @@
from django.contrib.auth import authenticate, login, logout
from django.middleware.csrf import get_token
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils.decorators import method_decorator
from rest_framework import permissions, status, viewsets
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.models import ApiKey, User
from apps.accounts.serializers import (
ApiKeyCreateSerializer,
ApiKeySerializer,
LoginSerializer,
UserSerializer,
)
class IsOwnerOrStaff(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if request.user.is_staff or request.user.is_superuser:
return True
return getattr(obj, "user_id", None) == request.user.pk or obj == request.user
class IsAdminUser(permissions.IsAdminUser):
"""Admin access for staff OR superadmin (superadmins may have is_staff=False)."""
def has_permission(self, request, view):
return bool(request.user and (request.user.is_staff or request.user.is_superuser))
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by("user_name")
serializer_class = UserSerializer
permission_classes = [IsAdminUser]
class ApiKeyViewSet(viewsets.ModelViewSet):
http_method_names = ["get", "post", "delete", "head", "options"]
def get_queryset(self):
qs = ApiKey.objects.select_related("user").order_by("-created_at")
if self.request.user.is_staff or self.request.user.is_superuser:
return qs
return qs.filter(user=self.request.user)
def get_serializer_class(self):
if self.action == "create":
return ApiKeyCreateSerializer
return ApiKeySerializer
def get_permissions(self):
return [permissions.IsAuthenticated()]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
name = serializer.validated_data["name"]
user = request.user
if (request.user.is_staff or request.user.is_superuser) and serializer.validated_data.get(
"user_id"
):
user = User.objects.get(pk=serializer.validated_data["user_id"])
api_key, raw = ApiKey.generate(user=user, name=name)
data = ApiKeySerializer(api_key).data
data["raw_key"] = raw
return Response(data, status=status.HTTP_201_CREATED)
def perform_destroy(self, instance):
if (
not self.request.user.is_staff
and not self.request.user.is_superuser
and instance.user_id != self.request.user.pk
):
raise permissions.PermissionDenied()
instance.delete()
@method_decorator(ensure_csrf_cookie, name="dispatch")
class CsrfView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def get(self, request):
return Response({"csrfToken": get_token(request)})
class LoginView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["user_name"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{"detail": "Invalid credentials"},
status=status.HTTP_401_UNAUTHORIZED,
)
login(request, user)
return Response(UserSerializer(user).data)
class LogoutView(APIView):
def post(self, request):
logout(request)
return Response(status=status.HTTP_204_NO_CONTENT)
class MeView(APIView):
def get(self, request):
return Response(UserSerializer(request.user).data)
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.contrib import admin
from apps.farms.models import Cycle, Flock, Kandang, Site
admin.site.register(Site)
admin.site.register(Kandang)
admin.site.register(Flock)
admin.site.register(Cycle)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class FarmsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.farms"
label = "farms"
verbose_name = "Farms"
@@ -0,0 +1,91 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('total_days', models.IntegerField()),
('start_date', models.DateField()),
('end_date', models.DateField()),
('doc_in_weight', models.IntegerField()),
('doc_in_count', models.IntegerField()),
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed')], default='active', max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'cycles',
'ordering': ['-start_date'],
},
),
migrations.CreateModel(
name='Flock',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('flock_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='flock_set', to='farms.cycle')),
],
options={
'db_table': 'flock',
'ordering': ['flock_name'],
},
),
migrations.AddField(
model_name='cycle',
name='flock',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.flock'),
),
migrations.CreateModel(
name='Site',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('site_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sites', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'sites',
'ordering': ['site_name'],
},
),
migrations.CreateModel(
name='Kandang',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kandang_name', models.CharField(max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kandangs', to='farms.site')),
],
options={
'db_table': 'kandang',
'ordering': ['kandang_name'],
},
),
migrations.AddField(
model_name='cycle',
name='kandang',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycles', to='farms.kandang'),
),
migrations.AddConstraint(
model_name='cycle',
constraint=models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='chk_cycles_date_range'),
),
]
@@ -0,0 +1,81 @@
# Generated by Django 5.2.17 on 2026-08-19
import django.db.models.deletion
from django.db import migrations, models
def _column_names(connection, table):
with connection.cursor() as cursor:
if connection.vendor == "sqlite":
cursor.execute(f"PRAGMA table_info({table})")
return {row[1] for row in cursor.fetchall()}
cursor.execute(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = %s
""",
[table],
)
return {row[0] for row in cursor.fetchall()}
def ensure_cycle_kandang_id(apps, schema_editor):
connection = schema_editor.connection
if "kandang_id" in _column_names(connection, "cycles"):
return
with connection.cursor() as cursor:
cursor.execute(
"ALTER TABLE cycles ADD COLUMN kandang_id bigint NULL REFERENCES kandang(id)"
)
def backfill_cycle_flocks(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle_id in Cycle.objects.filter(flock_id__isnull=True).values_list("pk", flat=True):
flock = Flock.objects.create(flock_name=f"Cycle {cycle_id}")
Cycle.objects.filter(pk=cycle_id).update(flock_id=flock.pk)
Flock.objects.filter(pk=flock.pk).update(cycle_id=cycle_id)
class Migration(migrations.Migration):
dependencies = [
("farms", "0001_initial"),
]
operations = [
migrations.RunPython(ensure_cycle_kandang_id, migrations.RunPython.noop),
migrations.RunPython(backfill_cycle_flocks, migrations.RunPython.noop),
migrations.AlterField(
model_name="cycle",
name="flock",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cycles",
to="farms.flock",
),
),
migrations.AlterField(
model_name="cycle",
name="status",
field=models.CharField(
choices=[("active", "Active"), ("closed", "Closed")],
db_index=True,
default="active",
max_length=30,
),
),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="flock_set",
to="farms.cycle",
),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def _sqlite_flock_cycle_notnull(connection):
with connection.cursor() as cursor:
cursor.execute("PRAGMA table_info(flock)")
cols = {row[1]: row for row in cursor.fetchall()}
cycle_col = cols.get("cycle_id")
return bool(cycle_col and cycle_col[3] == 1)
def make_flock_cycle_nullable(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
if not _sqlite_flock_cycle_notnull(connection):
return
with connection.cursor() as cursor:
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE flock RENAME TO flock_old")
cursor.execute(
"""
CREATE TABLE flock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
flock_name varchar(30) NOT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
cycle_id bigint NULL REFERENCES cycles(id)
)
"""
)
cursor.execute(
"""
INSERT INTO flock (id, flock_name, created_at, updated_at, cycle_id)
SELECT id, flock_name, created_at, updated_at, cycle_id FROM flock_old
"""
)
cursor.execute("DROP TABLE flock_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0002_cycle_requires_flock"),
]
operations = [
migrations.RunPython(make_flock_cycle_nullable, migrations.RunPython.noop),
]
@@ -0,0 +1,59 @@
# Generated by Django 5.2.17 on 2026-08-19
from django.db import migrations
def retarget_cycles_flock_fk(apps, schema_editor):
connection = schema_editor.connection
if connection.vendor != "sqlite":
return
with connection.cursor() as cursor:
cursor.execute("SELECT sql FROM sqlite_master WHERE name='cycles'")
row = cursor.fetchone()
if not row or "flock_old" not in (row[0] or ""):
return
cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("ALTER TABLE cycles RENAME TO cycles_old")
cursor.execute(
"""
CREATE TABLE cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
flock_id bigint NOT NULL REFERENCES flock(id) DEFERRABLE INITIALLY DEFERRED,
kandang_id bigint NOT NULL REFERENCES kandang(id) DEFERRABLE INITIALLY DEFERRED,
status varchar(30) NOT NULL,
CONSTRAINT chk_cycles_date_range CHECK (end_date >= start_date)
)
"""
)
cursor.execute(
"""
INSERT INTO cycles (
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
)
SELECT
id, total_days, start_date, end_date, doc_in_weight, doc_in_count,
created_at, updated_at, flock_id, kandang_id, status
FROM cycles_old
"""
)
cursor.execute("DROP TABLE cycles_old")
cursor.execute("PRAGMA foreign_keys=ON")
class Migration(migrations.Migration):
dependencies = [
("farms", "0003_flock_cycle_nullable"),
]
operations = [
migrations.RunPython(retarget_cycles_flock_fk, migrations.RunPython.noop),
]
@@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_erd_named_primary_keys"),
("farms", "0004_retarget_cycles_flock_fk"),
]
operations = [
migrations.RenameField(model_name="site", old_name="id", new_name="site_id"),
migrations.RenameField(model_name="kandang", old_name="id", new_name="kandang_id"),
migrations.RenameField(model_name="flock", old_name="id", new_name="flock_id"),
migrations.RenameField(model_name="cycle", old_name="id", new_name="cycle_id"),
]
@@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def backfill_flock_cycle_from_cycles(apps, schema_editor):
Cycle = apps.get_model("farms", "Cycle")
Flock = apps.get_model("farms", "Flock")
for cycle in Cycle.objects.exclude(flock_id__isnull=True).iterator():
Flock.objects.filter(pk=cycle.flock_id).update(cycle_id=cycle.pk)
Flock.objects.filter(cycle_id__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
]
operations = [
migrations.RunPython(backfill_flock_cycle_from_cycles, migrations.RunPython.noop),
migrations.AlterField(
model_name="flock",
name="cycle",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="flocks",
to="farms.cycle",
),
),
migrations.RemoveField(
model_name="cycle",
name="flock",
),
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0006_erd_flock_belongs_to_cycle"),
]
operations = [
migrations.AlterField(
model_name="cycle",
name="doc_in_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
]
operations = [
migrations.AddField(
model_name="cycle",
name="feed_initial_balance",
field=models.IntegerField(default=0),
),
]
Whitespace-only changes.
+135
View File
@@ -0,0 +1,135 @@
from django.core.exceptions import ValidationError
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.accounts.models import User
@register_snippet
class Site(models.Model):
site_id = models.BigAutoField(primary_key=True)
site_name = models.CharField(max_length=30)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sites")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("site_name"),
FieldPanel("user"),
]
class Meta:
db_table = "sites"
ordering = ["site_name"]
def __str__(self) -> str:
return self.site_name
@register_snippet
class Kandang(models.Model):
kandang_id = models.BigAutoField(primary_key=True)
kandang_name = models.CharField(max_length=30)
site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="kandangs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang_name"),
FieldPanel("site"),
]
class Meta:
db_table = "kandang"
ordering = ["kandang_name"]
def __str__(self) -> str:
return self.kandang_name
@register_snippet
class Flock(models.Model):
flock_id = models.BigAutoField(primary_key=True)
flock_name = models.CharField(max_length=30)
cycle = models.ForeignKey(
"Cycle",
on_delete=models.CASCADE,
related_name="flocks",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("flock_name"),
FieldPanel("cycle"),
]
class Meta:
db_table = "flock"
ordering = ["flock_name"]
def __str__(self) -> str:
return self.flock_name
@register_snippet
class Cycle(models.Model):
STATUS_ACTIVE = "active"
STATUS_CLOSED = "closed"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Active"),
(STATUS_CLOSED, "Closed"),
]
cycle_id = models.BigAutoField(primary_key=True)
total_days = models.IntegerField()
start_date = models.DateField()
end_date = models.DateField()
doc_in_weight = models.IntegerField()
doc_in_count = models.IntegerField()
feed_initial_balance = models.IntegerField(default=0)
status = models.CharField(
max_length=30, choices=STATUS_CHOICES, default=STATUS_ACTIVE, db_index=True
)
kandang = models.ForeignKey(Kandang, on_delete=models.CASCADE, related_name="cycles")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("kandang"),
FieldPanel("total_days"),
FieldPanel("start_date"),
FieldPanel("end_date"),
FieldPanel("doc_in_weight"),
FieldPanel("doc_in_count"),
FieldPanel("feed_initial_balance"),
FieldPanel("status"),
]
class Meta:
db_table = "cycles"
ordering = ["-start_date"]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F("start_date")),
name="chk_cycles_date_range",
)
]
def __str__(self) -> str:
return f"Cycle #{self.pk} ({self.status})"
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"})
@property
def current_day(self) -> int:
from django.utils import timezone
today = timezone.localdate()
if today <= self.start_date:
return 1
elapsed = (today - self.start_date).days + 1
return min(max(elapsed, 1), self.total_days)
+61
View File
@@ -0,0 +1,61 @@
from rest_framework import serializers
from apps.accounts.serializers import PkAsIdMixin
from apps.farms.models import Cycle, Flock, Kandang, Site
class SiteSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Site
fields = ["id", "site_name", "user", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class KandangSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Kandang
fields = ["id", "kandang_name", "site", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class FlockSerializer(PkAsIdMixin, serializers.ModelSerializer):
class Meta:
model = Flock
fields = ["id", "flock_name", "cycle", "created_at", "updated_at"]
read_only_fields = ["id", "created_at", "updated_at"]
class CycleSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.PrimaryKeyRelatedField(queryset=Kandang.objects.all())
flock = serializers.SerializerMethodField()
chick_in_weight = serializers.IntegerField(source="doc_in_weight")
current_day = serializers.IntegerField(read_only=True)
class Meta:
model = Cycle
fields = [
"id",
"kandang",
"flock",
"total_days",
"current_day",
"start_date",
"end_date",
"chick_in_weight",
"doc_in_count",
"feed_initial_balance",
"status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "flock", "feed_initial_balance", "created_at", "updated_at"]
def get_flock(self, obj):
flock = obj.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
def create(self, validated_data):
kandang = validated_data["kandang"]
cycle = Cycle.objects.create(**validated_data)
Flock.objects.create(flock_name=kandang.kandang_name[:30], cycle=cycle)
return cycle
+82
View File
@@ -0,0 +1,82 @@
from datetime import date, timedelta
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
class FarmAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", password="secret")
self.client = APIClient()
self.client.force_authenticate(self.user)
self.site = Site.objects.create(site_name="Sukawarna", user=self.user)
self.kandang = Kandang.objects.create(kandang_name="Kandang 1", site=self.site)
def test_create_cycle_auto_creates_flock_and_returns_erd_fields(self):
start = date.today()
response = self.client.post(
"/api/v1/cycles/",
{
"kandang": self.kandang.pk,
"total_days": 35,
"start_date": start.isoformat(),
"end_date": (start + timedelta(days=34)).isoformat(),
"chick_in_weight": 42,
"doc_in_count": 10000,
"status": "active",
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["chick_in_weight"], 42)
self.assertIsNotNone(response.data["flock"])
cycle = Cycle.objects.get(pk=response.data["id"])
flock = Flock.objects.get(pk=response.data["flock"])
self.assertEqual(cycle.doc_in_weight, 42)
self.assertEqual(flock.cycle_id, cycle.pk)
def test_list_cycles_by_kandang(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
response = self.client.get("/api/v1/cycles/", {"kandang_id": self.kandang.pk})
self.assertEqual(response.status_code, 200)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["flock"], flock.pk)
def test_list_flocks_by_cycle(self):
cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
flock = Flock.objects.create(flock_name="Flock A", cycle=cycle)
other_cycle = Cycle.objects.create(
kandang=self.kandang,
total_days=35,
start_date=date.today(),
end_date=date.today() + timedelta(days=34),
doc_in_weight=40,
doc_in_count=9000,
status=Cycle.STATUS_ACTIVE,
)
Flock.objects.create(flock_name="Flock B", cycle=other_cycle)
response = self.client.get("/api/v1/flocks/", {"cycle_id": cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual([r["id"] for r in results], [flock.pk])
+11
View File
@@ -0,0 +1,11 @@
from rest_framework.routers import DefaultRouter
from apps.farms.views import CycleViewSet, FlockViewSet, KandangViewSet, SiteViewSet
router = DefaultRouter()
router.register("sites", SiteViewSet, basename="site")
router.register("kandangs", KandangViewSet, basename="kandang")
router.register("flocks", FlockViewSet, basename="flock")
router.register("cycles", CycleViewSet, basename="cycle")
urlpatterns = router.urls
+172
View File
@@ -0,0 +1,172 @@
from datetime import date, timedelta
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
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.operations.models import FeedSacks, ManualInput
from apps.operations.services.karung_web import KarungWebError, request_karung
from apps.operations.services.manual_input import recompute_manual_totals
class IsSuperAdminForWrite(permissions.BasePermission):
"""Reads for any authenticated user; mutations only for superadmin.
Staff accounts may read sites/kandangs (to pick one and create a cycle) but
cannot create, edit, or delete them — only superadmins manage structures.
"""
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 request.user.status == User.STATUS_SUPERADMIN)
class SiteViewSet(viewsets.ModelViewSet):
queryset = Site.objects.select_related("user").all()
serializer_class = SiteSerializer
permission_classes = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
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 = [IsSuperAdminForWrite]
def get_queryset(self):
qs = super().get_queryset()
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.all()
serializer_class = FlockSerializer
def get_queryset(self):
qs = super().get_queryset()
cycle_id = self.request.query_params.get("cycle_id")
if cycle_id:
qs = qs.filter(cycle_id=cycle_id)
return qs
class CycleViewSet(viewsets.ModelViewSet):
queryset = Cycle.objects.select_related("kandang").prefetch_related("flocks").all()
serializer_class = CycleSerializer
def get_queryset(self):
qs = super().get_queryset()
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="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), records the manual feed-in
for the balance date, then pulls the IoT-detected "in" for that date from
the karung-web-admin feed 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,
)
cycle.feed_initial_balance = feed_in_manual
cycle.save(update_fields=["feed_initial_balance", "updated_at"])
# Pull IoT "in" at the balance date (karung-web-admin syncs today/yesterday).
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
sync_error = None
if feed_sack is None:
try:
today = date.today()
section = None
if balance_date == today:
section = "today"
elif balance_date == today - timedelta(days=1):
section = "yesterday_history"
if section:
request_karung(cycle, section=section)
feed_sack = FeedSacks.objects.filter(cycle=cycle, date=balance_date).first()
except (KarungWebError, ValidationError) as exc:
sync_error = str(exc)
iot_in = feed_sack.in_today if feed_sack is not None else None
# Record the manual feed-in for the balance date.
ManualInput.objects.update_or_create(
cycle=cycle,
date=balance_date,
defaults={"feed_in_manual": feed_in_manual},
)
recompute_manual_totals(cycle)
# FeedSacks in_total always includes the initial balance as its base.
running = cycle.feed_initial_balance
for fs_row in FeedSacks.objects.filter(cycle=cycle).order_by("date", "pk"):
running += fs_row.in_today
if fs_row.in_total != running:
fs_row.in_total = running
fs_row.save(update_fields=["in_total", "updated_at"])
accuracy = None
if feed_in_manual > 0 and iot_in is not None and iot_in > 0:
accuracy = round(min(feed_in_manual, iot_in) / max(feed_in_manual, iot_in) * 1000) / 10
elif feed_in_manual == 0 and iot_in == 0:
accuracy = 100.0
manual_row = ManualInput.objects.filter(cycle=cycle, date=balance_date).first()
return Response(
{
"cycle": cycle.pk,
"date": balance_date.isoformat(),
"feed_initial_balance": cycle.feed_initial_balance,
"manual": feed_in_manual,
"manual_total": manual_row.feed_in_manual_total if manual_row else feed_in_manual,
"iot": iot_in,
"accuracy": accuracy,
"sync_error": sync_error,
}
)
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,53 @@
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
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
def handle(self, *args, **options):
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
counting = ChickenCounting.objects.filter(cycle=cycle).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = FeedSacks.objects.filter(cycle=cycle).order_by("-date", "-pk").first()
latest_cc = ChickenCounting.objects.filter(cycle=cycle).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = ChickenWeight.objects.filter(cycle=cycle).order_by("-date").first()
latest_mi = ManualInput.objects.filter(cycle=cycle).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,48 @@
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
class Command(BaseCommand):
help = (
"Seed demo superadmin login + API key, then Sukawarna Kandang 4 "
"and Kandang 5 as the default closed cycles"
)
@transaction.atomic
def handle(self, *args, **options):
user, created = User.objects.get_or_create(
user_name="admin",
defaults={"status": User.STATUS_SUPERADMIN, "is_staff": True, "is_superuser": True},
)
if created or not user.has_usable_password():
user.set_password("admin123")
user.status = User.STATUS_SUPERADMIN
user.save()
self.stdout.write("Created/updated user admin / admin123")
raw_key = None
bootstrap = settings.BOOTSTRAP_API_KEY
if bootstrap:
api_key, raw_key = ApiKey.generate(user, "bootstrap")
api_key.prefix = bootstrap[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
raw_key = bootstrap
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
elif not ApiKey.objects.filter(user=user).exists():
api_key, raw_key = ApiKey.generate(user, "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: Sukawarna Kandang 4 & Kandang 5 closed cycles + 10-minute IoT panels"
)
)
@@ -0,0 +1,239 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 5 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, 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.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 5 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 5":
if is_lantai1:
return {"real": True}
return {
"wind_delta": -0.05,
"humidity_delta": 1.2,
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 4: 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 4 closed cycle.
Kandang 4 is a generated dataset modeled on the Kandang 5 seed
(``seed_sukawarna_kandang5``). The two houses run the same 49-day window at
the same site, but Kandang 4 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 5 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 5 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,
)
DEFAULT_PASSWORD = "staff123"
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)
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 4 closed cycle (generated, modeled on Kandang 5)"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
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"))
password = options["password"]
rng = random.Random(options["jitter_seed"])
user, created = User.objects.get_or_create(
user_name="staff",
defaults={"status": User.STATUS_ACTIVE, "is_staff": True},
)
user.status = User.STATUS_ACTIVE
user.is_staff = True
user.set_password(password)
user.save()
if created:
self.stdout.write(f"Created user staff / {password}")
else:
self.stdout.write(f"Updated password for staff / {password}")
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"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 4",
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__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 4 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, cycle=cycle)
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
# --- manual input ----------------------------------------------
# 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
life = DOC_IN_COUNT - mort_total - 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
life_pct = (DOC_IN_COUNT - mort_total) / DOC_IN_COUNT * 100.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": MORTALITY[i],
"mortality_manual_total": mort_total,
"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": MORTALITY[i],
"mortality_total": mort_total,
"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 += MORTALITY[i]
# --- IoT panel: Kandang 5 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(cycle=cycle).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,296 @@
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,
)
DEFAULT_PASSWORD = "staff123"
# --- 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]
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 5 closed cycle from preview xlsx + IoT extract"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
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}")
password = options["password"]
user, created = User.objects.get_or_create(
user_name="staff",
defaults={"status": User.STATUS_ACTIVE, "is_staff": True},
)
user.status = User.STATUS_ACTIVE
user.is_staff = True
user.set_password(password)
user.save()
if created:
self.stdout.write(f"Created user staff / {password}")
else:
self.stdout.write(f"Updated password for staff / {password}")
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"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 5",
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__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 5 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, cycle=cycle)
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(cycle=cycle).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,28 @@
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, request_karung
class Command(BaseCommand):
help = "Sync FeedSacks rows from karung-web-admin active sections"
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 = request_karung(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')}"
)
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 5 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 5).
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
+32
View File
@@ -0,0 +1,32 @@
from datetime import date, datetime
from io import BytesIO
from django.test import SimpleTestCase
from openpyxl import Workbook
from apps.jobs.sukawarna_preview import as_date, as_number, dated_rows
class SukawarnaPreviewHelpersTests(SimpleTestCase):
def test_as_date_accepts_iso_and_datetime(self):
self.assertEqual(as_date("2026-05-22"), date(2026, 5, 22))
self.assertEqual(as_date(datetime(2026, 7, 9, 12, 0)), date(2026, 7, 9))
self.assertIsNone(as_date("grams"))
self.assertIsNone(as_date("karung tuang Jul 4-8 updated"))
def test_dated_rows_skips_note_rows(self):
wb = Workbook()
ws = wb.active
ws.append(["date", "total_count"])
ws.append(["grams", "grams"])
ws.append(["2026-05-22", 13980])
bio = BytesIO()
wb.save(bio)
bio.seek(0)
from openpyxl import load_workbook
loaded = load_workbook(bio, data_only=True).active
rows = dated_rows(loaded)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["date"], date(2026, 5, 22))
self.assertEqual(as_number(rows[0]["total_count"]), 13980)
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
from django.contrib import admin
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
admin.site.register(KPI)
admin.site.register(ManualInput)
admin.site.register(ChickenCounting)
admin.site.register(ChickenWeight)
admin.site.register(FeedSacks)
admin.site.register(IotPanel)
admin.site.register(AIInsight)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class OperationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.operations"
label = "operations"
verbose_name = "Operations"
@@ -0,0 +1,199 @@
# Generated by Django 5.2.17 on 2026-08-18 08:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('farms', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='AIInsight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('insight_text', models.TextField()),
('alert', models.CharField(max_length=100)),
('section', models.CharField(max_length=100)),
('session', models.CharField(max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_insights', to='farms.cycle')),
],
options={
'db_table': 'ai_insight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenCounting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('total_count', models.IntegerField(default=0)),
('mortality_count', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_countings', to='farms.cycle')),
],
options={
'db_table': 'chicken_counting',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ChickenWeight',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField()),
('doc_weight', models.IntegerField()),
('average_weight', models.FloatField()),
('chicken_count', models.IntegerField()),
('uniformity', models.FloatField()),
('average_daily_gain', models.FloatField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chicken_weights', to='farms.cycle')),
],
options={
'db_table': 'chicken_weight',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='FeedSacks',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_today', models.IntegerField(default=0)),
('out_today', models.IntegerField(default=0)),
('in_total', models.IntegerField(default=0)),
('out_total', models.IntegerField(default=0)),
('feed_use_today', models.IntegerField(default=0)),
('feed_use_total', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feed_sacks', to='farms.cycle')),
],
options={
'verbose_name_plural': 'Feed sacks',
'db_table': 'feed_sacks',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='IotPanel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('wind_speed', models.FloatField(default=0.0)),
('humidity', models.FloatField(default=0.0)),
('water_total', models.FloatField(default=0.0)),
('average_temperature', models.FloatField(default=0.0)),
('experience_temperature', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('flock', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='iot_panels', to='farms.flock')),
],
options={
'verbose_name': 'IoT Panel',
'verbose_name_plural': 'IoT Panels',
'db_table': 'iot_panel',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='ManualInput',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age_manual', models.IntegerField(default=0)),
('mortality_manual', models.IntegerField(default=0)),
('mortality_manual_total', models.IntegerField(default=0)),
('feed_manual', models.IntegerField(default=0)),
('feed_manual_total', models.IntegerField(default=0)),
('harvest_manual', models.IntegerField(default=0)),
('harvest_manual_total', models.IntegerField(default=0)),
('harvest_weight_manual', models.IntegerField(default=0)),
('harvest_weight_manual_total', models.IntegerField(default=0)),
('manual_weight_float', models.FloatField(default=0.0)),
('average_harvest_day_manual', models.FloatField(default=0.0)),
('average_harvest_weight_manual', models.FloatField(default=0.0)),
('fcr_manual', models.FloatField(default=0.0)),
('eef_manual', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manual_inputs', to='farms.cycle')),
],
options={
'db_table': 'manual_input',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='KPI',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('age', models.IntegerField(default=0)),
('mortality', models.IntegerField(default=0)),
('mortality_total', models.IntegerField(default=0)),
('feed', models.IntegerField(default=0)),
('feed_total', models.IntegerField(default=0)),
('harvest', models.IntegerField(default=0)),
('harvest_total', models.IntegerField(default=0)),
('harvest_weight', models.IntegerField(default=0)),
('harvest_weight_total', models.IntegerField(default=0)),
('chicken_life', models.IntegerField(default=0)),
('chicken_life_percentage', models.FloatField(default=0.0)),
('lot_weight', models.FloatField(default=0.0)),
('average_harvest_day', models.FloatField(default=0.0)),
('average_harvest_weight', models.FloatField(default=0.0)),
('fcr', models.FloatField(default=0.0)),
('eef', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('cc', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickencounting')),
('cw', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.chickenweight')),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kpis', to='farms.cycle')),
('fs', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.feedsacks')),
('manual', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='kpis', to='operations.manualinput')),
],
options={
'verbose_name_plural': 'KPIs',
'db_table': 'kpi',
'ordering': ['-date'],
},
),
migrations.AddConstraint(
model_name='chickencounting',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_chicken_counting_cycle_date'),
),
migrations.AddConstraint(
model_name='chickenweight',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_cw_cycle_date'),
),
migrations.AddConstraint(
model_name='feedsacks',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_feed_sacks_cycle_date'),
),
migrations.AddConstraint(
model_name='iotpanel',
constraint=models.UniqueConstraint(fields=('flock', 'date'), name='uniq_iot_panel_flock_date'),
),
migrations.AddConstraint(
model_name='manualinput',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_manual_input_cycle_date'),
),
migrations.AddConstraint(
model_name='kpi',
constraint=models.UniqueConstraint(fields=('cycle', 'date'), name='uniq_kpi_cycle_date'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-08-19 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('operations', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='kpi',
name='harvest_weight',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='kpi',
name='harvest_weight_total',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='manualinput',
name='harvest_weight_manual_total',
field=models.FloatField(default=0.0),
),
]
@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("farms", "0005_erd_named_primary_keys"),
("operations", "0002_harvest_weight_float"),
]
operations = [
migrations.RenameField(model_name="iotpanel", old_name="id", new_name="panel_id"),
migrations.RenameField(model_name="feedsacks", old_name="id", new_name="fs_id"),
migrations.RenameField(model_name="chickencounting", old_name="id", new_name="cc_id"),
migrations.RenameField(model_name="chickenweight", old_name="id", new_name="cw_id"),
migrations.RenameField(model_name="manualinput", old_name="id", new_name="manual_id"),
migrations.RenameField(model_name="kpi", old_name="id", new_name="kpi_id"),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0003_erd_named_primary_keys"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="manual_weight_float",
new_name="manual_weight",
),
migrations.RenameField(
model_name="kpi",
old_name="lot_weight",
new_name="iot_weight",
),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.FloatField(),
),
]
@@ -0,0 +1,55 @@
from django.db import migrations, models
def backfill_feed_in_from_feed_sacks(apps, schema_editor):
ManualInput = apps.get_model("operations", "ManualInput")
FeedSacks = apps.get_model("operations", "FeedSacks")
feed_by_cycle_date = {
(fs.cycle_id, fs.date): fs
for fs in FeedSacks.objects.all().only("cycle_id", "date", "in_today", "in_total")
}
for manual in ManualInput.objects.all().only("cycle_id", "date"):
fs = feed_by_cycle_date.get((manual.cycle_id, manual.date))
if fs is None:
continue
manual.feed_in_manual = fs.in_today
manual.feed_in_manual_total = fs.in_total
manual.save(update_fields=["feed_in_manual", "feed_in_manual_total", "updated_at"])
class Migration(migrations.Migration):
dependencies = [
("operations", "0004_erd_column_updates"),
]
operations = [
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual",
new_name="feed_use_manual",
),
migrations.RenameField(
model_name="manualinput",
old_name="feed_manual_total",
new_name="feed_use_manual_total",
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_in_manual_total",
field=models.IntegerField(default=0),
),
migrations.RunPython(backfill_feed_in_from_feed_sacks, migrations.RunPython.noop),
migrations.AlterField(
model_name="chickenweight",
name="doc_weight",
field=models.IntegerField(),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0007_cycle_doc_in_weight_int"),
("operations", "0005_erd_feed_and_doc_weight"),
]
operations = [
migrations.RemoveConstraint(
model_name="iotpanel",
name="uniq_iot_panel_flock_date",
),
migrations.AddField(
model_name="iotpanel",
name="timestamp",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddConstraint(
model_name="iotpanel",
constraint=models.UniqueConstraint(
fields=("flock", "timestamp"), name="uniq_iot_panel_flock_timestamp"
),
),
]
@@ -0,0 +1,29 @@
from datetime import datetime, time
from django.db import migrations
from django.utils import timezone
def backfill_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
for row in IotPanel.objects.filter(timestamp__isnull=True):
row.timestamp = timezone.make_aware(
datetime.combine(row.date, time(0, 0)), timezone.get_current_timezone()
)
row.save(update_fields=["timestamp"])
def unset_timestamps(apps, schema_editor):
IotPanel = apps.get_model("operations", "IotPanel")
IotPanel.objects.update(timestamp=None)
class Migration(migrations.Migration):
dependencies = [
("operations", "0006_iotpanel_timestamp"),
]
operations = [
migrations.RunPython(backfill_timestamps, unset_timestamps),
]
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("operations", "0007_backfill_iotpanel_timestamp"),
]
operations = [
migrations.AddField(
model_name="manualinput",
name="feed_out_manual",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="manualinput",
name="feed_out_manual_total",
field=models.IntegerField(default=0),
),
]
@@ -0,0 +1,61 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("farms", "0008_cycle_feed_initial_balance"),
("operations", "0008_manualinput_feed_out_manual"),
]
operations = [
migrations.CreateModel(
name="ReportSnapshot",
fields=[
(
"id",
models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"),
),
(
"report_type",
models.CharField(
choices=[("daily", "Daily"), ("end_cycle", "End Cycle")], max_length=20
),
),
("up_to_day", models.IntegerField(default=0)),
("payload", models.JSONField(default=dict)),
("generated_at", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.cycle",
),
),
(
"kandang",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="report_snapshots",
to="farms.kandang",
),
),
],
options={
"verbose_name": "Report Snapshot",
"verbose_name_plural": "Report Snapshots",
"db_table": "report_snapshot",
"ordering": ["-updated_at"],
"constraints": [
models.UniqueConstraint(
fields=("cycle", "kandang", "report_type"),
name="uniq_report_snapshot_cycle_kandang_type",
)
],
},
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("operations", "0009_reportsnapshot"),
]
operations = [
migrations.DeleteModel(
name="ReportSnapshot",
),
]
Whitespace-only changes.
+198
View File
@@ -0,0 +1,198 @@
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.snippets.models import register_snippet
from apps.farms.models import Cycle, Flock
@register_snippet
class IotPanel(models.Model):
panel_id = models.BigAutoField(primary_key=True)
date = models.DateField()
timestamp = models.DateTimeField(null=True, blank=True)
wind_speed = models.FloatField(default=0.0)
humidity = models.FloatField(default=0.0)
water_total = models.FloatField(default=0.0)
average_temperature = models.FloatField(default=0.0)
experience_temperature = models.FloatField(default=0.0)
flock = models.ForeignKey(Flock, on_delete=models.CASCADE, related_name="iot_panels")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("flock"), FieldPanel("date"), FieldPanel("average_temperature")]
class Meta:
db_table = "iot_panel"
ordering = ["-date"]
verbose_name = "IoT Panel"
verbose_name_plural = "IoT Panels"
constraints = [
models.UniqueConstraint(fields=["flock", "timestamp"], name="uniq_iot_panel_flock_timestamp")
]
@register_snippet
class AIInsight(models.Model):
date = models.DateField()
insight_text = models.TextField()
alert = models.CharField(max_length=100)
section = models.CharField(max_length=100)
session = models.CharField(max_length=100)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("insight_text"), FieldPanel("alert")]
class Meta:
db_table = "ai_insight"
ordering = ["-date"]
@register_snippet
class FeedSacks(models.Model):
fs_id = models.BigAutoField(primary_key=True)
date = models.DateField()
in_today = models.IntegerField(default=0)
out_today = models.IntegerField(default=0)
in_total = models.IntegerField(default=0)
out_total = models.IntegerField(default=0)
feed_use_today = models.IntegerField(default=0)
feed_use_total = models.IntegerField(default=0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="feed_sacks")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("in_today"), FieldPanel("out_today")]
class Meta:
db_table = "feed_sacks"
ordering = ["-date"]
verbose_name_plural = "Feed sacks"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_feed_sacks_cycle_date")
]
@register_snippet
class ChickenCounting(models.Model):
cc_id = models.BigAutoField(primary_key=True)
date = models.DateField()
total_count = models.IntegerField(default=0)
mortality_count = models.IntegerField(default=0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="chicken_countings")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("total_count")]
class Meta:
db_table = "chicken_counting"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_chicken_counting_cycle_date")
]
@register_snippet
class ChickenWeight(models.Model):
cw_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age = models.IntegerField()
doc_weight = models.IntegerField()
average_weight = models.FloatField()
chicken_count = models.IntegerField()
uniformity = models.FloatField()
average_daily_gain = models.FloatField()
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="chicken_weights")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [
FieldPanel("cycle"),
FieldPanel("date"),
FieldPanel("average_weight"),
FieldPanel("uniformity"),
]
class Meta:
db_table = "chicken_weight"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_cw_cycle_date")
]
@register_snippet
class ManualInput(models.Model):
manual_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age_manual = models.IntegerField(default=0)
mortality_manual = models.IntegerField(default=0)
mortality_manual_total = models.IntegerField(default=0)
feed_in_manual = models.IntegerField(default=0)
feed_use_manual = models.IntegerField(default=0)
feed_in_manual_total = models.IntegerField(default=0)
feed_use_manual_total = models.IntegerField(default=0)
feed_out_manual = models.IntegerField(default=0)
feed_out_manual_total = models.IntegerField(default=0)
harvest_manual = models.IntegerField(default=0)
harvest_manual_total = models.IntegerField(default=0)
harvest_weight_manual = models.FloatField(default=0.0)
harvest_weight_manual_total = models.FloatField(default=0.0)
manual_weight = models.FloatField(default=0.0)
average_harvest_day_manual = models.FloatField(default=0.0)
average_harvest_weight_manual = models.FloatField(default=0.0)
fcr_manual = models.FloatField(default=0.0)
eef_manual = models.FloatField(default=0.0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="manual_inputs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age_manual"), FieldPanel("fcr_manual")]
class Meta:
db_table = "manual_input"
ordering = ["-date"]
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_manual_input_cycle_date")
]
@register_snippet
class KPI(models.Model):
kpi_id = models.BigAutoField(primary_key=True)
date = models.DateField()
age = models.IntegerField(default=0)
mortality = models.IntegerField(default=0)
mortality_total = models.IntegerField(default=0)
feed = models.IntegerField(default=0)
feed_total = models.IntegerField(default=0)
harvest = models.IntegerField(default=0)
harvest_total = models.IntegerField(default=0)
harvest_weight = models.FloatField(default=0.0)
harvest_weight_total = models.FloatField(default=0.0)
chicken_life = models.IntegerField(default=0)
chicken_life_percentage = models.FloatField(default=0.0)
iot_weight = models.FloatField(default=0.0)
average_harvest_day = models.FloatField(default=0.0)
average_harvest_weight = models.FloatField(default=0.0)
fcr = models.FloatField(default=0.0)
eef = models.FloatField(default=0.0)
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="kpis")
cc = models.ForeignKey(ChickenCounting, on_delete=models.RESTRICT, related_name="kpis")
cw = models.ForeignKey(ChickenWeight, on_delete=models.RESTRICT, related_name="kpis")
fs = models.ForeignKey(FeedSacks, on_delete=models.RESTRICT, related_name="kpis")
manual = models.ForeignKey(ManualInput, on_delete=models.RESTRICT, related_name="kpis")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
panels = [FieldPanel("cycle"), FieldPanel("date"), FieldPanel("age"), FieldPanel("fcr"), FieldPanel("eef")]
class Meta:
db_table = "kpi"
ordering = ["-date"]
verbose_name_plural = "KPIs"
constraints = [
models.UniqueConstraint(fields=["cycle", "date"], name="uniq_kpi_cycle_date")
]
+135
View File
@@ -0,0 +1,135 @@
from django.db import transaction
from rest_framework import serializers
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.accounts.serializers import PkAsIdMixin
from apps.operations.services.manual_input import recompute_manual_totals
class CycleContextMixin(serializers.Serializer):
kandang = serializers.IntegerField(source="cycle.kandang_id", read_only=True)
flock_id = serializers.SerializerMethodField()
def get_flock_id(self, obj):
flock = obj.cycle.flocks.order_by("flock_id").first()
return flock.pk if flock is not None else None
class KPISerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = KPI
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class ManualInputSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ManualInput
fields = "__all__"
read_only_fields = [
"id",
"created_at",
"updated_at",
"mortality_manual_total",
"feed_in_manual_total",
"feed_use_manual_total",
"harvest_manual_total",
"harvest_weight_manual_total",
"average_harvest_day_manual",
"average_harvest_weight_manual",
"fcr_manual",
"eef_manual",
]
validators = []
def create(self, validated_data):
return self._upsert(validated_data)
def update(self, instance, validated_data):
validated_data.setdefault("cycle", instance.cycle)
validated_data.setdefault("date", instance.date)
return self._upsert(validated_data, instance=instance)
def _upsert(self, validated_data, instance=None):
cycle = validated_data.get("cycle") or (instance.cycle if instance else None)
row_date = validated_data.get("date") or (instance.date if instance else None)
defaults = {
"age_manual": validated_data.get("age_manual", instance.age_manual if instance else 0),
"mortality_manual": validated_data.get(
"mortality_manual", instance.mortality_manual if instance else 0
),
"feed_in_manual": validated_data.get(
"feed_in_manual", instance.feed_in_manual if instance else 0
),
"feed_use_manual": validated_data.get(
"feed_use_manual", instance.feed_use_manual if instance else 0
),
"harvest_manual": validated_data.get(
"harvest_manual", instance.harvest_manual if instance else 0
),
"harvest_weight_manual": validated_data.get(
"harvest_weight_manual", instance.harvest_weight_manual if instance else 0
),
"manual_weight": validated_data.get(
"manual_weight", instance.manual_weight if instance else 0.0
),
}
with transaction.atomic():
row, _created = ManualInput.objects.update_or_create(
cycle=cycle,
date=row_date,
defaults=defaults,
)
recompute_manual_totals(cycle)
row.refresh_from_db()
return row
class ChickenCountingSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ChickenCounting
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class ChickenWeightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = ChickenWeight
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class FeedSacksSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = FeedSacks
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
class IotPanelSerializer(PkAsIdMixin, serializers.ModelSerializer):
kandang = serializers.SerializerMethodField()
flock_id = serializers.IntegerField(read_only=True)
class Meta:
model = IotPanel
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
def get_kandang(self, obj):
cycle = getattr(obj.flock, "cycle", None)
return cycle.kandang_id if cycle is not None else None
class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSerializer):
class Meta:
model = AIInsight
fields = "__all__"
read_only_fields = ["id", "created_at", "updated_at"]
Whitespace-only changes.
@@ -0,0 +1,106 @@
from __future__ import annotations
from datetime import date
from typing import Any
from urllib.parse import urljoin
import httpx
from django.conf import settings
from django.core.exceptions import ValidationError
from apps.farms.models import Cycle
from apps.operations.models import FeedSacks
class KarungWebError(Exception):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class KarungWebClient:
"""Outbound client for karung-web-admin — base URL from settings only."""
PATH = "/api/v1/cycles/active/sections"
def __init__(self, base_url: str | None = None, timeout: float | None = None):
self.base_url = (base_url or settings.KARUNG_WEB_ADMIN_BASE_URL).rstrip("/")
self.timeout = timeout or settings.KARUNG_WEB_ADMIN_TIMEOUT_SECONDS
def fetch_active_sections(self) -> dict[str, Any]:
url = urljoin(self.base_url + "/", self.PATH.lstrip("/"))
try:
with httpx.Client(timeout=self.timeout) as client:
response = client.get(url)
except httpx.HTTPError as exc:
raise KarungWebError(f"karung-web-admin request failed: {exc}") from exc
if response.status_code >= 400:
raise KarungWebError(
f"karung-web-admin HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
payload = response.json()
if not payload.get("success", True):
error = payload.get("error") or {}
raise KarungWebError(error.get("message") or "karung-web-admin returned failure")
return payload.get("data") or payload
def request_karung(
cycle: Cycle,
*,
section: str = "today",
) -> tuple[FeedSacks, dict[str, Any]]:
if section not in {"today", "yesterday_history"}:
raise ValidationError("section must be today or yesterday_history")
client = KarungWebClient()
data = client.fetch_active_sections()
sections = data.get("sections") or {}
block = sections.get(section) or {}
totals = block.get("totals") or {}
remote_cycle = data.get("cycle") or {}
in_today = int(totals.get("masuk") or 0)
out_today = int(totals.get("tuang") or 0)
feed_use_today = out_today
saldo_awal = int(remote_cycle.get("saldo_awal") or 0)
snapshot_raw = block.get("date")
snapshot_date = date.fromisoformat(str(snapshot_raw)) if snapshot_raw else date.today()
prior = (
FeedSacks.objects.filter(cycle=cycle)
.exclude(date=snapshot_date)
.order_by("-date", "-pk")
.first()
)
if prior:
in_total = prior.in_total + in_today
out_total = prior.out_total + out_today
feed_use_total = prior.feed_use_total + feed_use_today
else:
in_total = cycle.feed_initial_balance + in_today
out_total = out_today
feed_use_total = feed_use_today
feed_sack, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=snapshot_date,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": in_total,
"out_total": out_total,
"feed_use_today": feed_use_today,
"feed_use_total": feed_use_total,
},
)
summary = {
"section": section,
"snapshot_date": snapshot_date.isoformat(),
"totals": totals,
"saldo_awal": saldo_awal,
"remote_cycle": remote_cycle,
}
return feed_sack, summary
@@ -0,0 +1,37 @@
from __future__ import annotations
from apps.farms.models import Cycle
from apps.operations.models import ManualInput
DAILY_TO_TOTAL = (
("mortality_manual", "mortality_manual_total"),
("feed_in_manual", "feed_in_manual_total"),
("feed_use_manual", "feed_use_manual_total"),
("harvest_manual", "harvest_manual_total"),
("harvest_weight_manual", "harvest_weight_manual_total"),
)
SKIPPED_DERIVED_FIELDS = (
"average_harvest_day_manual",
"average_harvest_weight_manual",
"fcr_manual",
"eef_manual",
)
def recompute_manual_totals(cycle: Cycle) -> None:
running = {total: 0 for _, total in DAILY_TO_TOTAL}
rows = list(ManualInput.objects.filter(cycle=cycle).order_by("date", "pk"))
for row in rows:
changed: list[str] = []
for daily, total in DAILY_TO_TOTAL:
running[total] += getattr(row, daily) or 0
if getattr(row, total) != running[total]:
setattr(row, total, running[total])
changed.append(total)
for field in SKIPPED_DERIVED_FIELDS:
if getattr(row, field) != 0:
setattr(row, field, 0)
changed.append(field)
if changed:
row.save(update_fields=[*changed, "updated_at"])
+333
View File
@@ -0,0 +1,333 @@
from datetime import date, timedelta
from django.test import TestCase
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 ChickenWeight, FeedSacks, IotPanel
class OperationsAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(user_name="tester", 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=35,
start_date=date.today() - timedelta(days=2),
end_date=date.today() + timedelta(days=32),
doc_in_weight=40,
doc_in_count=10000,
status=Cycle.STATUS_ACTIVE,
)
self.flock = Flock.objects.create(flock_name="Flock A", cycle=self.cycle)
def test_karungs_alias_lists_feed_sacks_with_date(self):
FeedSacks.objects.create(
cycle=self.cycle,
date=date.today(),
in_today=10,
out_today=8,
feed_use_today=8,
feed_use_total=8,
)
response = self.client.get("/api/v1/karungs/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(results[0]["date"], date.today().isoformat())
self.assertEqual(results[0]["feed_use_total"], 8)
self.assertEqual(results[0]["kandang"], self.kandang.pk)
self.assertEqual(results[0]["flock_id"], self.flock.pk)
def test_karungs_latest_returns_newest_row(self):
FeedSacks.objects.create(cycle=self.cycle, date=date.today() - timedelta(days=1), out_today=1)
newest = FeedSacks.objects.create(cycle=self.cycle, date=date.today(), out_today=9)
response = self.client.get("/api/v1/karungs/latest/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["id"], newest.pk)
def test_panel_iot_accepts_cycle_id(self):
IotPanel.objects.create(
flock=self.flock,
date=date.today(),
average_temperature=28.5,
)
response = self.client.get("/api/v1/panel-iot/", {"cycle_id": self.cycle.pk})
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["date"], date.today().isoformat())
def test_panel_iot_filters_by_flock(self):
flock_b = Flock.objects.create(flock_name="Flock B", cycle=self.cycle)
row = IotPanel.objects.create(
flock=self.flock,
date=date.today(),
average_temperature=28.5,
)
IotPanel.objects.create(
flock=flock_b,
date=date.today(),
average_temperature=30.0,
)
response = self.client.get(
"/api/v1/panel-iot/",
{"cycle_id": self.cycle.pk, "flock_id": self.flock.pk},
)
self.assertEqual(response.status_code, 200, response.data)
results = response.data["results"] if isinstance(response.data, dict) else response.data
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["id"], row.pk)
def test_chicken_counting_uses_date_not_cameras(self):
response = self.client.post(
"/api/v1/chicken-countings/",
{
"cycle": self.cycle.pk,
"date": date.today().isoformat(),
"total_count": 9800,
"mortality_count": 12,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["date"], date.today().isoformat())
self.assertNotIn("counting_date", response.data)
self.assertNotIn("cam1_count", response.data)
def test_weights_latest_avg_uses_date(self):
ChickenWeight.objects.create(
cycle=self.cycle,
date=date.today(),
age=10,
doc_weight=40,
average_weight=320.5,
chicken_count=9800,
uniformity=82.0,
average_daily_gain=28.0,
)
response = self.client.get(
"/api/v1/chicken-weights/latest-avg/", {"cycle_id": self.cycle.pk}
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["date"], date.today().isoformat())
self.assertEqual(response.data["average_weight"], 320.5)
self.assertNotIn("prediction_date", response.data)
def test_weights_request_endpoint_is_removed(self):
response = self.client.post(
"/api/v1/chicken-weights/request/",
{"cycle_id": self.cycle.pk, "mode": "fetch"},
format="json",
)
self.assertEqual(response.status_code, 405)
def test_health_does_not_probe_fatchicken(self):
response = self.client.get("/api/v1/health/")
self.assertEqual(response.status_code, 200)
self.assertNotIn("fatchicken", response.data)
self.assertNotIn("fatchicken_base_configured", response.data)
def test_manual_input_uses_erd_field_names(self):
response = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": date.today().isoformat(),
"age_manual": 10,
"mortality_manual": 5,
"mortality_manual_total": 5,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["mortality_manual_total"], 5)
self.assertNotIn("mortality_total_manual", response.data)
def test_manual_input_computes_totals_and_ignores_client_derived_fields(self):
day1 = date.today() - timedelta(days=1)
day2 = date.today()
first = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day1.isoformat(),
"age_manual": 9,
"mortality_manual": 5,
"feed_in_manual": 8,
"feed_use_manual": 10,
"harvest_manual": 2,
"harvest_weight_manual": 8,
"manual_weight": 320.5,
"mortality_manual_total": 999,
"feed_in_manual_total": 999,
"feed_use_manual_total": 999,
"harvest_manual_total": 999,
"harvest_weight_manual_total": 999,
"fcr_manual": 1.9,
"eef_manual": 400,
"average_harvest_day_manual": 12,
"average_harvest_weight_manual": 99,
},
format="json",
)
self.assertIn(first.status_code, (200, 201), first.data)
self.assertEqual(first.data["age_manual"], 9)
self.assertEqual(first.data["mortality_manual_total"], 5)
self.assertEqual(first.data["feed_in_manual_total"], 8)
self.assertEqual(first.data["feed_use_manual_total"], 10)
self.assertEqual(first.data["harvest_manual_total"], 2)
self.assertEqual(first.data["harvest_weight_manual_total"], 8)
self.assertEqual(first.data["fcr_manual"], 0)
self.assertEqual(first.data["eef_manual"], 0)
self.assertEqual(first.data["average_harvest_day_manual"], 0)
self.assertEqual(first.data["average_harvest_weight_manual"], 0)
second = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day2.isoformat(),
"age_manual": 10,
"mortality_manual": 3,
"feed_in_manual": 2,
"feed_use_manual": 4,
"harvest_manual": 1,
"harvest_weight_manual": 2,
"manual_weight": 330.0,
},
format="json",
)
self.assertIn(second.status_code, (200, 201), second.data)
self.assertEqual(second.data["mortality_manual_total"], 8)
self.assertEqual(second.data["feed_in_manual_total"], 10)
self.assertEqual(second.data["feed_use_manual_total"], 14)
self.assertEqual(second.data["harvest_manual_total"], 3)
self.assertEqual(second.data["harvest_weight_manual_total"], 10)
update_day1 = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day1.isoformat(),
"age_manual": 9,
"mortality_manual": 7,
"feed_in_manual": 8,
"feed_use_manual": 10,
"harvest_manual": 2,
"harvest_weight_manual": 8,
"manual_weight": 320.5,
},
format="json",
)
self.assertIn(update_day1.status_code, (200, 201), update_day1.data)
self.assertEqual(update_day1.data["id"], first.data["id"])
self.assertEqual(update_day1.data["mortality_manual_total"], 7)
refreshed_day2 = self.client.get(
"/api/v1/manual-inputs/", {"cycle_id": self.cycle.pk, "date": day2.isoformat()}
)
self.assertEqual(refreshed_day2.status_code, 200, refreshed_day2.data)
rows = (
refreshed_day2.data["results"]
if isinstance(refreshed_day2.data, dict)
else refreshed_day2.data
)
self.assertEqual(rows[0]["mortality_manual_total"], 10)
def test_manual_input_accepts_fractional_harvest_weight(self):
day1 = date.today() - timedelta(days=1)
day2 = date.today()
first = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day1.isoformat(),
"harvest_weight_manual": 8.5,
},
format="json",
)
self.assertIn(first.status_code, (200, 201), first.data)
self.assertEqual(first.data["harvest_weight_manual"], 8.5)
self.assertEqual(first.data["harvest_weight_manual_total"], 8.5)
second = self.client.post(
"/api/v1/manual-inputs/",
{
"cycle": self.cycle.pk,
"date": day2.isoformat(),
"harvest_weight_manual": 1.25,
},
format="json",
)
self.assertIn(second.status_code, (200, 201), second.data)
self.assertEqual(second.data["harvest_weight_manual"], 1.25)
self.assertEqual(second.data["harvest_weight_manual_total"], 9.75)
def test_kpi_accepts_fractional_harvest_weight(self):
from apps.operations.models import ChickenCounting, FeedSacks, KPI, ManualInput
cc = ChickenCounting.objects.create(cycle=self.cycle, date=date.today(), total_count=9800)
cw = ChickenWeight.objects.create(
cycle=self.cycle,
date=date.today(),
age=10,
doc_weight=40,
average_weight=320.5,
chicken_count=9800,
uniformity=82.0,
average_daily_gain=28.0,
)
fs = FeedSacks.objects.create(cycle=self.cycle, date=date.today())
manual = ManualInput.objects.create(cycle=self.cycle, date=date.today())
response = self.client.post(
"/api/v1/kpis/",
{
"cycle": self.cycle.pk,
"date": date.today().isoformat(),
"cc": cc.pk,
"cw": cw.pk,
"fs": fs.pk,
"manual": manual.pk,
"harvest_weight": 12.75,
"harvest_weight_total": 20.5,
},
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["harvest_weight"], 12.75)
self.assertEqual(response.data["harvest_weight_total"], 20.5)
kpi = KPI.objects.get(pk=response.data["id"])
self.assertEqual(kpi.harvest_weight, 12.75)
self.assertEqual(kpi.harvest_weight_total, 20.5)
class ErdPrimaryKeyTests(TestCase):
ERD_PRIMARY_KEYS = {
"user_access": "user_id",
"sites": "site_id",
"kandang": "kandang_id",
"flock": "flock_id",
"cycles": "cycle_id",
"iot_panel": "panel_id",
"ai_insight": "id",
"feed_sacks": "fs_id",
"chicken_counting": "cc_id",
"chicken_weight": "cw_id",
"manual_input": "manual_id",
"kpi": "kpi_id",
}
def test_domain_primary_keys_match_erd_names(self):
from django.db import connection
with connection.cursor() as cursor:
for table, pk_name in self.ERD_PRIMARY_KEYS.items():
cursor.execute(f'PRAGMA table_info("{table}")')
live_pks = [row[1] for row in cursor.fetchall() if row[5]]
self.assertEqual(live_pks, [pk_name], table)
+29
View File
@@ -0,0 +1,29 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from apps.operations.views import (
AIInsightViewSet,
ChickenCountingViewSet,
ChickenWeightViewSet,
FeedSacksViewSet,
HealthView,
IotPanelViewSet,
KPIViewSet,
ManualInputViewSet,
)
router = DefaultRouter()
router.register("kpis", KPIViewSet, basename="kpi")
router.register("manual-inputs", ManualInputViewSet, basename="manual-input")
router.register("chicken-countings", ChickenCountingViewSet, basename="chicken-counting")
router.register("chicken-weights", ChickenWeightViewSet, basename="chicken-weight")
router.register("feed-sacks", FeedSacksViewSet, basename="feed-sacks")
router.register("karungs", FeedSacksViewSet, basename="karung")
router.register("iot-panels", IotPanelViewSet, basename="iot-panel")
router.register("panel-iot", IotPanelViewSet, basename="panel-iot")
router.register("ai-insights", AIInsightViewSet, basename="ai-insight")
urlpatterns = [
path("health/", HealthView.as_view(), name="health"),
*router.urls,
]
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.farms.models import Cycle
from apps.operations.models import (
AIInsight,
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
from apps.operations.serializers import (
AIInsightSerializer,
ChickenCountingSerializer,
ChickenWeightSerializer,
FeedSacksSerializer,
IotPanelSerializer,
KPISerializer,
ManualInputSerializer,
)
from apps.operations.services.karung_web import KarungWebError, request_karung
class CycleScopedViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
if params.get("cycle_id"):
qs = qs.filter(cycle_id=params["cycle_id"])
if params.get("flock_id"):
qs = qs.filter(cycle__flocks__flock_id=params["flock_id"]).distinct()
if params.get("date"):
qs = qs.filter(date=params["date"])
return qs
class KPIViewSet(CycleScopedViewSet):
queryset = KPI.objects.select_related("cycle").all()
serializer_class = KPISerializer
class ManualInputViewSet(CycleScopedViewSet):
queryset = ManualInput.objects.select_related("cycle").all()
serializer_class = ManualInputSerializer
class ChickenCountingViewSet(CycleScopedViewSet):
queryset = ChickenCounting.objects.select_related("cycle").all()
serializer_class = ChickenCountingSerializer
class ChickenWeightViewSet(CycleScopedViewSet):
queryset = ChickenWeight.objects.select_related("cycle").all()
serializer_class = ChickenWeightSerializer
@action(detail=False, methods=["get"], url_path="latest-avg")
def latest_average(self, request):
cycle_id = request.query_params.get("cycle_id")
if not cycle_id:
return Response(
{"detail": "cycle_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
row = self.get_queryset().filter(cycle_id=cycle_id).order_by("-date", "-pk").first()
if row is None:
return Response(
{"detail": "No chicken weight exists for this cycle."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(self.get_serializer(row).data)
class FeedSacksViewSet(CycleScopedViewSet):
queryset = FeedSacks.objects.select_related("cycle").all()
serializer_class = FeedSacksSerializer
@action(detail=False, methods=["get"], url_path="latest")
def latest(self, request):
cycle_id = request.query_params.get("cycle_id")
if not cycle_id:
return Response(
{"detail": "cycle_id is required."},
status=status.HTTP_400_BAD_REQUEST,
)
row = self.get_queryset().filter(cycle_id=cycle_id).order_by("-date", "-pk").first()
if row is None:
return Response(
{"detail": "No feed sacks row exists for this cycle."},
status=status.HTTP_404_NOT_FOUND,
)
return Response(self.get_serializer(row).data)
@action(detail=False, methods=["post"], url_path="request")
def request_sync(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.get(pk=cycle_id)
except Cycle.DoesNotExist:
return Response({"detail": "Cycle not found."}, status=status.HTTP_404_NOT_FOUND)
section = request.data.get("section") or "today"
try:
feed_sack, summary = request_karung(cycle, section=section)
except (KarungWebError, 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(
{"karung": self.get_serializer(feed_sack).data, "upstream": summary}
)
class IotPanelViewSet(viewsets.ModelViewSet):
queryset = IotPanel.objects.select_related("flock", "flock__cycle").all()
serializer_class = IotPanelSerializer
def get_queryset(self):
qs = super().get_queryset()
params = self.request.query_params
if params.get("cycle_id"):
qs = qs.filter(flock__cycle_id=params["cycle_id"])
if params.get("flock_id"):
qs = qs.filter(flock_id=params["flock_id"])
if params.get("date"):
qs = qs.filter(date=params["date"])
return qs
def paginate_queryset(self, queryset):
if self.request.query_params.get("date"):
return None
return super().paginate_queryset(queryset)
@action(detail=False, methods=["get"], url_path="dates")
def dates(self, request):
dates = list(
self.get_queryset()
.order_by("date")
.values_list("date", flat=True)
.distinct()
)
return Response(dates)
class AIInsightViewSet(CycleScopedViewSet):
queryset = AIInsight.objects.select_related("cycle").all()
serializer_class = AIInsightSerializer
class HealthView(APIView):
permission_classes = [AllowAny]
authentication_classes = []
def get(self, request):
payload = {
"status": "ok",
"service": "newdashboard-api",
"karung_web_admin_base_configured": bool(settings.KARUNG_WEB_ADMIN_BASE_URL),
}
return Response(payload)
View File
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()
+6
View File
@@ -0,0 +1,6 @@
bind = "0.0.0.0:8000"
workers = 2
threads = 2
timeout = 120
accesslog = "-"
errorlog = "-"
+177
View File
@@ -0,0 +1,177 @@
"""Django settings for New Dashboard (ERD-aligned Wagtail + DRF)."""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
def env(key: str, default: str | None = None) -> str | None:
return os.getenv(key, default)
def env_bool(key: str, default: bool = False) -> bool:
value = os.getenv(key)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def env_list(key: str, default: str = "") -> list[str]:
raw = os.getenv(key, default)
return [item.strip() for item in raw.split(",") if item.strip()]
SECRET_KEY = env("SECRET_KEY", "insecure-dev-key")
DEBUG = env_bool("DEBUG", True)
ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:8000")
INSTALLED_APPS = [
"apps.accounts",
"apps.farms",
"apps.operations",
"apps.jobs",
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail",
"modelcluster",
"taggit",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"drf_spectacular",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
DB_ENGINE = env("DB_ENGINE", "django.db.backends.sqlite3")
if DB_ENGINE == "django.db.backends.sqlite3":
DATABASES = {
"default": {
"ENGINE": DB_ENGINE,
"NAME": env("DB_NAME", str(BASE_DIR / "db.sqlite3")),
}
}
else:
DATABASES = {
"default": {
"ENGINE": DB_ENGINE,
"NAME": env("DB_NAME", "newdashboard"),
"USER": env("DB_USER", "newdashboard"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST", "localhost"),
"PORT": env("DB_PORT", "5432"),
}
}
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "Asia/Jakarta"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WAGTAIL_SITE_NAME = env("WAGTAIL_SITE_NAME", "New Dashboard")
WAGTAILADMIN_BASE_URL = env("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
WAGTAIL_APPEND_SLASH = False
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS", "http://localhost:3000")
CORS_ALLOW_CREDENTIALS = env_bool("CORS_ALLOW_CREDENTIALS", True)
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False)
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_HTTPONLY = False
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"apps.accounts.authentication.ApiKeyAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": int(env("API_PAGE_SIZE", "50") or "50"),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "New Dashboard API",
"DESCRIPTION": "ERD-aligned poultry dashboard API (session + API key auth).",
"VERSION": "1.0.0",
}
BOOTSTRAP_API_KEY = env("BOOTSTRAP_API_KEY", "") or ""
KARUNG_WEB_ADMIN_BASE_URL = (
env("KARUNG_WEB_ADMIN_BASE_URL", "http://127.0.0.1:5000") or ""
).rstrip("/")
KARUNG_WEB_ADMIN_TIMEOUT_SECONDS = float(
env("KARUNG_WEB_ADMIN_TIMEOUT_SECONDS", "30") or "30"
)
KARUNG_WEB_ADMIN_SYNC_ENABLED = env_bool("KARUNG_WEB_ADMIN_SYNC_ENABLED", False)
CRON_ADVANCE_CYCLE_DAY = env_bool("CRON_ADVANCE_CYCLE_DAY", True)
+27
View File
@@ -0,0 +1,27 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/docs/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("api/v1/", include("apps.accounts.urls")),
path("api/v1/", include("apps.farms.urls")),
path("api/v1/", include("apps.operations.urls")),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()
@@ -0,0 +1,325 @@
# Entity Relationship Diagram (ERD) & Unified Master Architecture
This document defines the integrated **Audrix Smart Poultry Platform ERD (`ERD_audrix`)** and how the **Chicken Counting & Weight Estimation Vision System** connects directly into the master farm database.
---
## 🗺️ Unified Master Mermaid ERD (`ERD_audrix` Integration)
```mermaid
erDiagram
%% ==========================================
%% 1. CORE ENTERPRISE HIERARCHY (ERD_audrix)
%% ==========================================
USER_ACCESS ||--|{ SITES : "manages"
SITES ||--|{ KANDANG : "contains"
KANDANG ||--|{ CYCLES : "runs"
CYCLES ||--|{ FLOCK : "houses"
%% ==========================================
%% 2. OUR DOMAIN: EDGE TOPOLOGY & VISION
%% ==========================================
KANDANG ||--|{ FLOOR : "has_levels"
FLOOR ||--|{ CAMERA : "equipped_with"
FLOOR ||--o{ BATCH_RUNS : "executes"
CAMERA ||--o{ BATCH_RUNS : "records"
BATCH_RUNS ||--o{ DETECTIONS : "captures_tracks"
%% ==========================================
%% 3. VISION ROLLUPS -> MASTER AUDRIX TABLES
%% ==========================================
CYCLES ||--o{ CHICKEN_COUNTING : "records_daily_count"
CYCLES ||--o{ CHICKEN_WEIGHT : "records_daily_weight"
BATCH_RUNS ||--o{ CHICKEN_COUNTING : "aggregates_into_cc"
BATCH_RUNS ||--o{ CHICKEN_WEIGHT : "aggregates_into_cw"
%% ==========================================
%% 4. NON-VISION EXTERNAL STREAMS (DO NOT TOUCH)
%% ==========================================
FLOCK ||--o{ IOT_PANEL : "monitored_by"
CYCLES ||--o{ FEED_SACKS : "tracks_inventory"
CYCLES ||--o{ MANUAL_INPUT : "logs_ground_truth"
CYCLES ||--o{ AI_INSIGHT : "generates_alerts"
%% ==========================================
%% 5. MASTER KPI AGGREGATION (ERD_audrix)
%% ==========================================
CYCLES ||--o{ KPI : "computes_daily_kpi"
CHICKEN_COUNTING ||--o{ KPI : "feeds_headcount"
CHICKEN_WEIGHT ||--o{ KPI : "feeds_vision_weight"
FEED_SACKS ||--o{ KPI : "feeds_feed_usage"
MANUAL_INPUT ||--o{ KPI : "feeds_manual_weights"
%% ==========================================
%% ENTITY DEFINITIONS
%% ==========================================
USER_ACCESS {
int user_id PK "NOT NULL"
varchar user_name "30 NOT NULL"
varchar password_hash "255 NOT NULL"
varchar status "30 NOT NULL"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
SITES {
int site_id PK "NOT NULL"
varchar site_name "30 NOT NULL"
int user_id FK "References USER_ACCESS"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
KANDANG {
int kandang_id PK "NOT NULL"
varchar kandang_name "30 NOT NULL"
int site_id FK "References SITES"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
CYCLES {
int cycle_id PK "NOT NULL (Flock Batch Run)"
int kandang_id FK "References KANDANG"
int total_days "NOT NULL"
date start_date "NOT NULL (Day 0)"
date end_date "NOT NULL"
int doc_in_weight "NOT NULL (Initial DOC weight for this cycle)"
int doc_in_count "NOT NULL (Stocking inventory)"
varchar status "30 NOT NULL"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
FLOCK {
int flock_id PK "NOT NULL"
varchar flock_name "30 NOT NULL"
int cycle_id FK "References CYCLES"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
FLOOR {
string floor_id PK "e.g. K1-L1, K1-L2"
int kandang_id FK "References KANDANG"
string level "Floor level designation (L1, L2, L3)"
json roi_points "Counting gate coordinates"
float px_per_cm "Spatial calibration constant"
}
CAMERA {
string camera_id PK "e.g. CC1, CC2, CC3, CC4"
string floor_id FK "References FLOOR"
int camera_num "1 to 4"
float px_per_cm "Camera pixel to cm calibration"
string stream_url "Source video / RTSP stream"
}
BATCH_RUNS {
integer id PK "AUTOINCREMENT (SQLite edge runs)"
string date "YYYY-MM-DD"
string location FK "References FLOOR(floor_id)"
string camera_id FK "References CAMERA(camera_id)"
integer total_entered "Headcount count validated"
integer frames_processed
real elapsed_seconds
string stopped_reason
string source_video
string generated_at
integer cycle_day "0 = DOC count only, 1+ = Weight active"
real area_cm2_median "Median body area (cm^2)"
real minor_axis_cm_median "Median width (cm)"
real major_axis_cm_median "Median length (cm)"
real predicted_weight_g "Estimated weight (g)"
real gompertz_baseline_g "Expected curve weight (g)"
real vision_gain_g "Vision allometric gain (g)"
integer n_weight_samples "Valid sample count"
real rejection_rate "MAD outlier filter rejection rate"
}
DETECTIONS {
string date "YYYY-MM-DD"
string camera_id FK
integer track_id "BoT-SORT track identifier"
integer class_id "0 = chicken"
float confidence "YOLO confidence score"
integer frame_index "Count trigger frame"
float bbox_area_cm2 "Area in cm^2"
float width_cm "Width in cm"
float height_cm "Height in cm"
float minor_axis_cm "min(width, height) cm"
float major_axis_cm "max(width, height) cm"
float aspect_ratio_cm "width / height"
float perimeter_cm "Perimeter cm"
float mad_zscore "MAD Z-score outlier metric"
}
CHICKEN_COUNTING {
int cc_id PK "NOT NULL"
int cycle_id FK "References CYCLES"
date date "NOT NULL"
int total_count "NOT NULL (Aggregated total from edge BATCH_RUNS)"
int mortality_count "NOT NULL (Carcasses from vision segmentation)"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
CHICKEN_WEIGHT {
int cw_id PK "NOT NULL"
int cycle_id FK "References CYCLES"
date date "NOT NULL"
int age "NOT NULL (Cycle Day: 1+)"
int doc_weight "NOT NULL (Cycle-specific initial DOC weight)"
float average_weight "NOT NULL (predicted_weight_g from edge)"
int chicken_count "NOT NULL (n_weight_samples)"
float uniformity "NOT NULL (Flock weight uniformity %)"
float average_daily_gain "NOT NULL (ADG in grams/day)"
float area_cm2_median "Median body area in cm^2"
float minor_axis_cm_median "Median minor axis / width in cm"
float major_axis_cm_median "Median major axis / length in cm"
float gompertz_baseline_g "Theoretical Gompertz curve baseline"
float vision_gain_g "Allometric vision gain residual"
float rejection_rate "MAD outlier filter rejection percentage"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
}
IOT_PANEL {
int panel_id PK "NOT NULL"
date date "NOT NULL"
float wind_speed "NOT NULL"
float humidity "NOT NULL"
float water_total "NOT NULL"
float average_temperature "NOT NULL"
float experience_temperature "NOT NULL"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
int flock_id FK "References FLOCK"
}
FEED_SACKS {
int fs_id PK "NOT NULL (fc_id)"
date date "NOT NULL"
int in_today "NOT NULL"
int out_today "NOT NULL"
int in_total "NOT NULL"
int out_total "NOT NULL"
int feed_use_today "NOT NULL"
int feed_use_total "NOT NULL"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
int cycle_id FK "References CYCLES"
}
MANUAL_INPUT {
int manual_id PK "NOT NULL"
date date "NOT NULL"
int age_manual "NOT NULL"
int mortality_manual "NOT NULL"
int mortality_manual_total "NOT NULL"
int feed_in_manual "NOT NULL"
int feed_use_manual "NOT NULL"
int feed_in_manual_total "NOT NULL"
int feed_use_manual_total "NOT NULL"
int harverst_manual "NOT NULL"
int harverst_manual_total "NOT NULL"
float harvest_weight_manual "NOT NULL"
float harvest_weight_manual_total "NOT NULL"
float manual_weight "NOT NULL"
float average_harvest_day_manual "NOT NULL"
float average_harvest_weight_manual "NOT NULL"
float fcr_manual "NOT NULL"
float eef_manual "NOT NULL"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
int cycle_id FK "References CYCLES"
}
AI_INSIGHT {
int id PK "NOT NULL"
date date "NOT NULL"
text insight_text "NOT NULL"
string alert "NOT NULL"
varchar section "100 NOT NULL"
varchar session "100 NOT NULL"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
int cycle_id FK "References CYCLES"
}
KPI {
int kpi_id PK "NOT NULL"
date date "NOT NULL"
int age "NOT NULL"
int mortality "NOT NULL"
int mortality_total "NOT NULL"
int feed "NOT NULL"
int feed_total "NOT NULL"
int harverst "NOT NULL"
int harverst_total "NOT NULL"
float harvest_weight "NOT NULL"
float harvest_weight_total "NOT NULL"
int chicken_life "NOT NULL"
float chicken_life_percentage "NOT NULL"
float iot_weight "NOT NULL"
float average_harvest_day "NOT NULL"
float average_harvest_weight "NOT NULL"
float fcr "NOT NULL (Feed Conversion Ratio)"
float eef "NOT NULL (European Efficiency Factor)"
timestamp created_at "NOT NULL"
timestamp updated_at "NOT NULL"
int cycle_id FK "References CYCLES"
int cc_id FK "References CHICKEN_COUNTING"
int cw_id FK "References CHICKEN_WEIGHT"
int fs_id FK "References FEED_SACKS"
int manual_id FK "References MANUAL_INPUT"
}
```
---
## 🏗️ Architectural Tier Descriptions & Integration Mapping
### Tier 1: Farm Hierarchy (`USER_ACCESS` $\to$ `SITES` $\to$ `KANDANG` $\to$ `CYCLES` $\to$ `FLOCK`)
- Belongs to master `ERD_audrix`.
- Defines farm ownership, coops, operational commercial cycles (`CYCLES`), and biological flocks (`FLOCK`).
---
### Tier 2: Edge Vision Subsystem (`FLOOR` $\to$ `CAMERA` $\to$ `BATCH_RUNS` $\to$ `DETECTIONS`)
- **Physical Hierarchy:** Each `KANDANG` has multiple floor levels (`FLOOR`), and each floor operates 4 top-down optical counting cameras (`CAMERA`: CC1, CC2, CC3, CC4) calibrated by `px_per_cm`.
- **Execution & Curation:** Daily batch runs process video feeds on the edge and populate SQLite table `BATCH_RUNS` and Parquet observation datasets (`DETECTIONS`).
- **Cycle Day 0 Rule:** Day 0 is strictly for initial stocking headcount (`total_entered`), while Day 1+ calculates both headcount and vision-derived geometric body weights.
---
### Tier 3: Vision Aggregations to Audrix Tables (`CHICKEN_COUNTING` & `CHICKEN_WEIGHT`)
Our edge pipeline aggregates floor camera runs and pushes clean daily rollups directly into the two vision tables of `ERD_audrix`:
1. **`CHICKEN_COUNTING` (`cc_id`)**:
- `total_count` $\leftarrow \sum \text{total\_entered}$ across all floor cameras for that date.
- `mortality_count` $\leftarrow \text{total dead carcasses detected by mortality segmentation}$.
2. **`CHICKEN_WEIGHT` (`cw_id`)**:
- `average_weight` $\leftarrow \text{fused predicted weight in grams}$.
- `chicken_count` $\leftarrow \sum \text{n\_weight\_samples}$ (valid non-outlier tracks).
- `uniformity` $\leftarrow \text{flock size uniformity percentage}$.
- `average_daily_gain` $\leftarrow \text{daily weight gain in grams/day}$.
- `area_cm2_median`, `minor_axis_cm_median`, `major_axis_cm_median` $\leftarrow \text{calibrated physical dimensions}$.
- `gompertz_baseline_g`, `vision_gain_g` $\leftarrow \text{audited growth model residual breakdown}$.
---
### Tier 4: Master Analytics & KPI Synthesis (`KPI`)
- `KPI` is the central analytics ledger that combines:
- Vision counts from `CHICKEN_COUNTING` (`cc_id`).
- Vision body weights from `CHICKEN_WEIGHT` (`cw_id`).
- Daily feed intake from `FEED_SACKS` (`fs_id`).
- Human ground truth logs from `MANUAL_INPUT` (`manual_id`).
- Computes macro biological & economic efficiency indicators:
$$\text{FCR} = \frac{\text{Total Feed Consumed (kg)}}{\text{Total Harvest / Live Weight (kg)}}$$
$$\text{EEF} = \frac{\text{Livability \%} \times \text{Average Weight (kg)}}{\text{Age (Days)} \times \text{FCR}} \times 100$$
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+290
View File
@@ -0,0 +1,290 @@
-- =============================================================================
-- PostgreSQL Schema — CPSP Dashboard ERD
-- Source: ERD_dashboard.png (updated 2026-08-20)
--
-- user_access.status: active | inactive | superadmin
-- (Django syncs is_active/is_staff/is_superuser from status for auth internals)
-- user_access → sites → kandang → cycles
-- cycles → flock (flock.cycle_id, required; cycles has no flock_id)
-- flock → iot_panel
-- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight,
-- manual_input, kpi
-- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. User Access
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS user_access (
user_id SERIAL PRIMARY KEY,
user_name VARCHAR(30) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
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
);
CREATE INDEX IF NOT EXISTS idx_user_access_status ON user_access (status);
-- -----------------------------------------------------------------------------
-- 2. Sites
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sites (
site_id SERIAL PRIMARY KEY,
site_name VARCHAR(30) NOT NULL,
user_id INTEGER NOT NULL REFERENCES user_access (user_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_sites_user_id ON sites (user_id);
-- -----------------------------------------------------------------------------
-- 3. Kandang (Coop / Pen)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kandang (
kandang_id SERIAL PRIMARY KEY,
kandang_name VARCHAR(30) NOT NULL,
site_id INTEGER NOT NULL REFERENCES sites (site_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_kandang_site_id ON kandang (site_id);
-- -----------------------------------------------------------------------------
-- 4. Cycles
-- No flock_id — flock belongs to cycle via flock.cycle_id.
-- -----------------------------------------------------------------------------
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,
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)
);
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);
-- -----------------------------------------------------------------------------
-- 5. Flock
-- -----------------------------------------------------------------------------
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,
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);
-- -----------------------------------------------------------------------------
-- 6. IoT Panel
-- One row per sensor reading; the panel polls every 10 minutes, so a cycle
-- day holds up to 144 rows. `timestamp` carries the reading time; `date` is
-- kept denormalized for fast daily grouping.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS iot_panel (
panel_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE,
wind_speed FLOAT NOT NULL,
humidity FLOAT NOT NULL,
water_total FLOAT NOT NULL,
average_temperature FLOAT NOT NULL,
experience_temperature FLOAT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
flock_id INTEGER NOT NULL REFERENCES flock (flock_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_iot_panel_flock_id ON iot_panel (flock_id);
CREATE INDEX IF NOT EXISTS idx_iot_panel_date ON iot_panel (date);
CREATE INDEX IF NOT EXISTS idx_iot_panel_timestamp ON iot_panel (timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (flock_id, timestamp);
-- -----------------------------------------------------------------------------
-- 7. AI Insight
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ai_insight (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
insight_text TEXT NOT NULL,
alert VARCHAR(100) NOT NULL,
section VARCHAR(100) NOT NULL,
session VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date);
-- -----------------------------------------------------------------------------
-- 8. Feed Sacks
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS feed_sacks (
fs_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
in_today INTEGER NOT NULL DEFAULT 0,
out_today INTEGER NOT NULL DEFAULT 0,
in_total INTEGER NOT NULL DEFAULT 0,
out_total INTEGER NOT NULL DEFAULT 0,
feed_use_today INTEGER NOT NULL DEFAULT 0,
feed_use_total INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_feed_sacks_cycle_id ON feed_sacks (cycle_id);
CREATE INDEX IF NOT EXISTS idx_feed_sacks_date ON feed_sacks (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_feed_sacks_cycle_date ON feed_sacks (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 9. Chicken Counting
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chicken_counting (
cc_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
mortality_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chicken_counting_cycle_id ON chicken_counting (cycle_id);
CREATE INDEX IF NOT EXISTS idx_chicken_counting_date ON chicken_counting (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_counting_cycle_date ON chicken_counting (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 10. Chicken Weight
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS chicken_weight (
cw_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
age INTEGER NOT NULL,
doc_weight INTEGER NOT NULL,
average_weight FLOAT NOT NULL,
chicken_count INTEGER NOT NULL,
uniformity FLOAT NOT NULL,
average_daily_gain FLOAT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id);
CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_weight_cycle_date ON chicken_weight (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 11. Manual Input
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS manual_input (
manual_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
age_manual INTEGER NOT NULL DEFAULT 0,
mortality_manual INTEGER NOT NULL DEFAULT 0,
mortality_manual_total INTEGER NOT NULL DEFAULT 0,
feed_in_manual INTEGER NOT NULL DEFAULT 0,
feed_use_manual INTEGER NOT NULL DEFAULT 0,
feed_in_manual_total INTEGER NOT NULL DEFAULT 0,
feed_use_manual_total INTEGER NOT NULL DEFAULT 0,
feed_out_manual INTEGER NOT NULL DEFAULT 0,
feed_out_manual_total INTEGER NOT NULL DEFAULT 0,
harvest_manual INTEGER NOT NULL DEFAULT 0,
harvest_manual_total INTEGER NOT NULL DEFAULT 0,
harvest_weight_manual FLOAT NOT NULL DEFAULT 0,
harvest_weight_manual_total FLOAT NOT NULL DEFAULT 0,
manual_weight FLOAT NOT NULL DEFAULT 0,
average_harvest_day_manual FLOAT NOT NULL DEFAULT 0,
average_harvest_weight_manual FLOAT NOT NULL DEFAULT 0,
fcr_manual FLOAT NOT NULL DEFAULT 0,
eef_manual FLOAT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_manual_input_cycle_id ON manual_input (cycle_id);
CREATE INDEX IF NOT EXISTS idx_manual_input_date ON manual_input (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_manual_input_cycle_date ON manual_input (cycle_id, date);
-- -----------------------------------------------------------------------------
-- 12. KPI (aggregates data from cycle + source records)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS kpi (
kpi_id SERIAL PRIMARY KEY,
date DATE NOT NULL,
age INTEGER NOT NULL DEFAULT 0,
mortality INTEGER NOT NULL DEFAULT 0,
mortality_total INTEGER NOT NULL DEFAULT 0,
feed INTEGER NOT NULL DEFAULT 0,
feed_total INTEGER NOT NULL DEFAULT 0,
harvest INTEGER NOT NULL DEFAULT 0,
harvest_total INTEGER NOT NULL DEFAULT 0,
harvest_weight FLOAT NOT NULL DEFAULT 0,
harvest_weight_total FLOAT NOT NULL DEFAULT 0,
chicken_life INTEGER NOT NULL DEFAULT 0,
chicken_life_percentage FLOAT NOT NULL DEFAULT 0,
iot_weight FLOAT NOT NULL DEFAULT 0,
average_harvest_day FLOAT NOT NULL DEFAULT 0,
average_harvest_weight FLOAT NOT NULL DEFAULT 0,
fcr FLOAT NOT NULL DEFAULT 0,
eef FLOAT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
cc_id INTEGER NOT NULL REFERENCES chicken_counting (cc_id) ON DELETE RESTRICT,
cw_id INTEGER NOT NULL REFERENCES chicken_weight (cw_id) ON DELETE RESTRICT,
fs_id INTEGER NOT NULL REFERENCES feed_sacks (fs_id) ON DELETE RESTRICT,
manual_id INTEGER NOT NULL REFERENCES manual_input (manual_id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS idx_kpi_cycle_id ON kpi (cycle_id);
CREATE INDEX IF NOT EXISTS idx_kpi_date ON kpi (date);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_kpi_cycle_date ON kpi (cycle_id, date);
-- =============================================================================
-- Auto-update updated_at on row changes
-- =============================================================================
CREATE OR REPLACE FUNCTION erd_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
DECLARE
tbl TEXT;
BEGIN
FOREACH tbl IN ARRAY ARRAY[
'user_access', 'sites', 'kandang', 'cycles', 'flock',
'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting',
'chicken_weight', 'manual_input', 'kpi'
]
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS trg_%I_updated_at ON %I', tbl, tbl);
EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION erd_set_updated_at()',
tbl, tbl
);
END LOOP;
END;
$$;
+56
View File
@@ -0,0 +1,56 @@
/**
* Run Django management commands from this backend folder.
* Usage:
* node dev.mjs # runserver 0.0.0.0:8000
* node dev.mjs migrate
* node dev.mjs seed_demo
*/
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const djangoRoot = path.dirname(fileURLToPath(import.meta.url));
if (!existsSync(path.join(djangoRoot, 'manage.py'))) {
console.error('[backend] manage.py not found.');
process.exit(1);
}
const extra = process.argv.slice(2);
const manageArgs =
extra.length === 0
? ['manage.py', 'runserver', '0.0.0.0:8000']
: ['manage.py', ...extra];
function resolvePython() {
const isWin = process.platform === 'win32';
const venvPython = isWin
? path.join(djangoRoot, '.venv', 'Scripts', 'python.exe')
: path.join(djangoRoot, '.venv', 'bin', 'python');
if (existsSync(venvPython)) {
return venvPython;
}
return isWin ? 'python' : 'python3';
}
const python = resolvePython();
const child = spawn(python, manageArgs, {
cwd: djangoRoot,
stdio: 'inherit',
env: process.env,
});
child.on('error', (err) => {
console.error(`[backend] Failed to start ${python}:`, err.message);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.exit(1);
}
process.exit(code ?? 0);
});
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libjpeg62-turbo-dev \
zlib1g-dev \
libpq-dev \
cron \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN chmod +x docker/entrypoint-api.sh docker/entrypoint-cron.sh \
&& mkdir -p /app/data /app/media /app/staticfiles \
&& cp docker/crontab /etc/cron.d/newdashboard \
&& chmod 0644 /etc/cron.d/newdashboard \
&& crontab /etc/cron.d/newdashboard
EXPOSE 8000
ENTRYPOINT ["./docker/entrypoint-api.sh"]
+6
View File
@@ -0,0 +1,6 @@
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
0 * * * * root cd /app && /usr/local/bin/python manage.py advance_cycle_days >> /var/log/cron.log 2>&1
15 * * * * root cd /app && /usr/local/bin/python manage.py recompute_kpi_rollups >> /var/log/cron.log 2>&1
*/15 * * * * root cd /app && /usr/local/bin/python manage.py sync_karung_from_web_admin >> /var/log/cron.log 2>&1
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
cd /app
python manage.py migrate --noinput
python manage.py collectstatic --noinput
exec gunicorn config.wsgi:application -c config/gunicorn.py
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
cd /app
python manage.py migrate --noinput
# Ensure cron env can see Django settings
printenv | grep -E '^(PATH|DJANGO_|SECRET_|DB_|KARUNG_|CRON_|WAGTAIL_|DEBUG|ALLOWED_|BOOTSTRAP_|API_|CORS_|CSRF_|SESSION_)' \
> /etc/environment || true
exec cron -f
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Is it installed and available on your PYTHONPATH?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
{
"name": "dashboard-new-v2-backend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Django API for dashboard-new-v2",
"scripts": {
"dev": "node dev.mjs",
"migrate": "node dev.mjs migrate",
"seed": "node dev.mjs seed_demo",
"start": "node dev.mjs"
}
}
+11
View File
@@ -0,0 +1,11 @@
Django>=5.1,<5.3
wagtail>=6.3,<7.0
djangorestframework>=3.15,<4.0
drf-spectacular>=0.27,<1.0
django-cors-headers>=4.4,<5.0
python-dotenv>=1.0,<2.0
gunicorn>=23.0,<24.0
psycopg[binary]>=3.2,<4.0
httpx>=0.27,<1.0
Pillow>=10.0,<12.0
openpyxl>=3.1,<4.0
+181
View File
@@ -0,0 +1,181 @@
import React, { useEffect, useMemo, useState } from 'react';
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
const CobbStandardPage: React.FC = () => {
const { selectedCycle } = useFarm();
const [initialBw, setInitialBw] = useState<number>(selectedCycle?.chick_in_weight || 42);
const [capacity, setCapacity] = useState<number>(selectedCycle?.doc_in_count || 50000);
useEffect(() => {
if (selectedCycle?.chick_in_weight) setInitialBw(selectedCycle.chick_in_weight);
if (selectedCycle?.doc_in_count) setCapacity(selectedCycle.doc_in_count);
}, [selectedCycle?.chick_in_weight, selectedCycle?.doc_in_count]);
const calculatedData = useMemo(() => {
const results = [];
let lastBw = Number(initialBw);
let lastCumFeed = 0;
for (const standard of cobbStandardPerformance) {
const currentBw = lastBw * (1 + standard.dailyGainPercent);
const dailyGainG = currentBw - lastBw;
const cumFeedG = currentBw * standard.fcr;
const dailyFeedG = cumFeedG - lastCumFeed;
const feedKg = (dailyFeedG * capacity) / 1000;
const feedBag = feedKg / 50;
results.push({
day: standard.day,
bw: Math.round(currentBw),
dailyGainG: Math.round(dailyGainG),
dailyGainPercent: standard.dailyGainPercent,
dailyFeedG: Math.round(dailyFeedG),
cumFeedG: Math.round(cumFeedG),
fcr: standard.fcr,
feedKg: Math.round(feedKg),
feedBag: Math.round(feedBag),
});
lastBw = currentBw;
lastCumFeed = cumFeedG;
}
return results;
}, [initialBw, capacity]);
return (
<main className="pb-8">
<div className="flex items-center mb-6">
<i className="fa-solid fa-calculator text-3xl text-red-600 mr-4"></i>
<div>
<h1 className="text-3xl font-bold text-gray-800">Kalkulator Standar Performa</h1>
<p className="mt-1 text-gray-600">
Hitung proyeksi pertumbuhan dan kebutuhan pakan berdasarkan bobot awal dan kapasitas
kandang.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-white p-4 rounded-xl shadow-sm">
<label htmlFor="initialBw" className="block text-sm font-medium text-gray-700">
Bobot Awal DOC (g)
</label>
<input
type="number"
id="initialBw"
value={initialBw}
onChange={(e) => setInitialBw(Number(e.target.value))}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-lg font-semibold"
/>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm">
<label htmlFor="capacity" className="block text-sm font-medium text-gray-700">
Kapasitas Kandang (ekor)
</label>
<input
type="number"
id="capacity"
value={capacity}
onChange={(e) => setCapacity(Number(e.target.value))}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 text-lg font-semibold"
/>
</div>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm overflow-hidden">
<div className="overflow-auto max-h-[calc(100vh-380px)]">
<table className="min-w-full border-collapse text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th
rowSpan={2}
className="p-2 border border-gray-800 font-semibold sticky left-0 bg-gray-700 z-20"
>
Umur
</th>
<th rowSpan={2} className="p-2 border border-gray-800 font-semibold">
BW (g)
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Daily Gain
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Feed Consumption
</th>
<th rowSpan={2} className="p-2 border border-gray-800 font-semibold">
FCR
</th>
<th colSpan={2} className="p-2 border border-gray-800 font-semibold">
Kebutuhan Pakan
</th>
</tr>
<tr>
<th className="p-2 border border-gray-800 font-semibold">(g)</th>
<th className="p-2 border border-gray-800 font-semibold">(%)</th>
<th className="p-2 border border-gray-800 font-semibold">Daily (g)</th>
<th className="p-2 border border-gray-800 font-semibold">Cum (g)</th>
<th className="p-2 border border-gray-800 font-semibold">(Kg)</th>
<th className="p-2 border border-gray-800 font-semibold">(Bag)</th>
</tr>
</thead>
<tbody className="font-mono">
<tr className="bg-yellow-100 font-bold">
<td className="p-1 border border-gray-200 text-center bg-gray-700 text-white sticky left-0 z-10">
0
</td>
<td className="p-1 border border-gray-200 text-center text-gray-700">
{initialBw}
</td>
<td className="p-1 border border-gray-200 text-center" colSpan={7}></td>
</tr>
{calculatedData.map((row) => {
const isSeventhDay = row.day > 0 && row.day % 7 === 0;
const rowClass = `hover:bg-red-50 ${isSeventhDay ? 'font-bold bg-blue-100' : 'even:bg-gray-100/50'}`;
const cellBorderClass = isSeventhDay
? 'border-b-[3px] border-double border-blue-400'
: 'border-gray-200';
return (
<tr key={row.day} className={rowClass}>
<td
className={`p-1 border ${cellBorderClass} text-center bg-gray-700 text-white font-semibold sticky left-0 z-10`}
>
{row.day}
</td>
<td
className={`p-1 border ${cellBorderClass} text-center font-bold text-red-600`}
>
{formatNumber(row.bw)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.dailyGainG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{(row.dailyGainPercent * 100).toFixed(0)}%
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.dailyFeedG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.cumFeedG)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatRatio(row.fcr)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.feedKg)}
</td>
<td className={`p-1 border ${cellBorderClass} text-center text-gray-700`}>
{formatNumber(row.feedBag)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="text-right mt-2 text-xs text-gray-500 font-semibold">
Standar Performa COBB (2018)
</div>
</main>
);
};
export default CobbStandardPage;
+173
View File
@@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import { useFarm } from '../context/FarmContext.tsx';
import { formatDateId, formatDateRange } 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 phases: Phase[] = [
{ name: 'Starter', startDay: 1, endDay: starterEnd, color: 'bg-red-400' },
];
if (growerEnd > starterEnd) {
phases.push({
name: 'Grower',
startDay: starterEnd + 1,
endDay: growerEnd,
color: 'bg-orange-400',
});
}
if (totalDays > growerEnd) {
phases.push({
name: 'Finisher',
startDay: growerEnd + 1,
endDay: totalDays,
color: 'bg-yellow-500',
});
}
return phases;
}
const statusConfig = {
active: { label: 'Active', bg: 'bg-red-100', text: 'text-red-800', icon: 'fa-running' },
closed: {
label: 'Completed',
bg: 'bg-green-100',
text: 'text-green-800',
icon: 'fa-check-circle',
},
};
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 progress = cycle.total_days > 0 ? (currentDay / cycle.total_days) * 100 : 0;
return (
<div className="pt-4">
<div className="flex flex-col sm:flex-row justify-between sm:items-center mb-4 gap-4">
<p className="text-sm text-gray-500">
Siklus:{' '}
<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>
</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;
return (
<div
key={phase.name}
className={`h-full ${phase.color} flex items-center justify-center text-white text-sm font-bold`}
style={{ width: `${width}%` }}
title={`${phase.name} (Hari ${phase.startDay}-${phase.endDay})`}
>
<span className="hidden sm:inline opacity-90">{phase.name}</span>
</div>
);
})}
</div>
{cycle.status === 'active' && currentDay > 0 && (
<div
className="absolute top-0 transition-all duration-500 ease-out"
style={{ left: `calc(${progress}% - 2.5rem)` }}
>
<div className="relative w-24 text-center">
<div className="font-bold text-red-600 bg-red-100 px-3 py-1.5 rounded-md shadow-sm border border-red-200 text-base">
Hari ke-{currentDay}
</div>
<div className="absolute left-1/2 -translate-x-1/2 w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent border-t-8 border-t-red-100" />
</div>
</div>
)}
<div className="flex justify-between items-center text-sm font-semibold text-gray-500 mt-2 px-1">
<div className="flex flex-col items-start">
<span>Hari 1</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.start_date)}
</span>
<span className="text-xs font-normal text-gray-400">
Berat DOC: {cycle.chick_in_weight} gram
</span>
<span className="text-xs font-normal text-gray-400">
DOC In: {cycle.doc_in_count.toLocaleString('id-ID')} ekor
</span>
</div>
<div className="flex flex-col items-end">
<span>Hari {cycle.total_days}</span>
<span className="text-xs font-normal text-gray-400">
{formatDateId(cycle.end_date)}
</span>
</div>
</div>
</div>
</div>
);
};
const CycleTimeline: React.FC = () => {
const { cycles, selectedCycleId, setSelectedCycleId } = useFarm();
const [localId, setLocalId] = useState<number | null>(selectedCycleId);
useEffect(() => {
setLocalId(selectedCycleId);
}, [selectedCycleId]);
const selected = cycles.find((c) => c.id === localId) ?? null;
return (
<div className="bg-white p-6 sm:p-8 rounded-xl shadow-sm mb-8">
<h2 className="text-2xl font-bold text-gray-800 mb-4">Linimasa Siklus Produksi</h2>
{cycles.length === 0 ? (
<p className="text-sm text-gray-500">
Belum ada siklus untuk kandang ini. Buat di Pengaturan.
</p>
) : (
<>
<div className="border-b border-gray-200 overflow-x-auto">
<nav className="-mb-px flex space-x-6 min-w-max" aria-label="Tabs">
{cycles.map((cycle) => {
const config = statusConfig[cycle.status];
const isActive = cycle.id === selected?.id;
return (
<button
key={cycle.id}
onClick={() => {
setLocalId(cycle.id);
setSelectedCycleId(cycle.id);
}}
className={`flex items-center gap-2 px-3 py-2 text-sm font-semibold rounded-t-lg transition-colors border-b-2 whitespace-nowrap ${
isActive
? 'border-red-500 text-red-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<i className={`fa-solid ${config.icon}`} />
<span>
{formatDateId(cycle.start_date)} ({config.label})
</span>
</button>
);
})}
</nav>
</div>
{selected ? (
<SingleCycleView cycle={selected} />
) : (
<p className="text-sm text-gray-500 mt-4">
Pilih tab siklus di atas, atau buat di Pengaturan.
</p>
)}
</>
)}
</div>
);
};
export default CycleTimeline;
+331
View File
@@ -0,0 +1,331 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api, ApiError, errorMessage } from '../services/apiClient.ts';
import type {
AIInsight,
ChickenCounting,
ChickenWeight,
Karung,
KPI,
LatestAvg,
} from '../types/api.ts';
import type { View } from '../types/common.ts';
import { useFarm } from '../context/FarmContext.tsx';
import KpiCard, { type KpiCardModel } from './KpiCard.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatDateRange } from '../utils/format.ts';
import DashboardReportCtas from './dashboard/DashboardReportCtas.tsx';
import DashboardManualInputForm from './dashboard/DashboardManualInputForm.tsx';
import ReportPreviewOverlay from './dashboard/ReportPreviewOverlay.tsx';
import type { DashboardReportType } from '../types/dashboardReport.ts';
type Props = { setView: (view: View) => void };
function chronological<T>(rows: T[]): T[] {
return [...rows].reverse();
}
const Dashboard: React.FC<Props> = ({ setView }) => {
const { selectedCycle, selectedKandang, selectedSite } = useFarm();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [kpis, setKpis] = useState<KPI[]>([]);
const [countings, setCountings] = useState<ChickenCounting[]>([]);
const [weights, setWeights] = useState<ChickenWeight[]>([]);
const [karung, setKarung] = useState<Karung | null>(null);
const [latestAvg, setLatestAvg] = useState<LatestAvg | null>(null);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [reportOpen, setReportOpen] = useState(false);
const [reportType, setReportType] = useState<DashboardReportType>('daily');
useEffect(() => {
if (!selectedCycle) {
setKpis([]);
setCountings([]);
setWeights([]);
setKarung(null);
setLatestAvg(null);
setInsights([]);
return;
}
const cycleId = selectedCycle.id;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const [kpiRows, countingRows, weightRows, insightRows] = await Promise.all([
api.kpis.list({ cycle_id: cycleId }),
api.countings.list({ cycle_id: cycleId }),
api.weights.list({ cycle_id: cycleId }),
api.insights.list({ cycle_id: cycleId }),
]);
let latestKarung: Karung | null = null;
try {
latestKarung = await api.karungs.latest({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
let avg: LatestAvg | null = null;
try {
avg = await api.weights.latestAvg({ cycle_id: cycleId });
} catch (err) {
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
if (cancelled) return;
setKpis(kpiRows);
setCountings(countingRows);
setWeights(weightRows);
setKarung(latestKarung);
setLatestAvg(avg);
setInsights(insightRows);
} catch (err) {
if (!cancelled) setError(errorMessage(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle]);
const cards: KpiCardModel[] = useMemo(() => {
const latest = kpis[0];
const fcrTrend = chronological(kpis).map((row) => row.fcr);
const eefTrend = chronological(kpis).map((row) => row.eef);
const popTrend = chronological(countings).map((row) => row.total_count);
const weightTrend = chronological(weights).map((row) => row.average_weight);
const age = latest?.age ?? selectedCycle?.current_day ?? null;
return [
{
key: 'umur',
title: 'Umur',
value: age,
unit: 'hari',
icon: '📅',
status: age == null ? 'na' : 'ok',
},
{
key: 'populasi',
title: 'Populasi',
value: latest?.chicken_life ?? countings[0]?.total_count ?? null,
unit: 'ekor',
icon: '🐔',
trend: popTrend,
status: 'ok',
targetView: 'dataManagement',
},
{
key: 'karung',
title: 'Karung Dituang',
value: karung?.out_today ?? karung?.feed_use_today ?? null,
unit: 'karung',
icon: '🛢️',
status: karung ? 'ok' : 'na',
targetView: 'feedSackCounting',
},
{
key: 'weight',
title: 'Berat AVG',
value: latestAvg?.average_weight ?? weights[0]?.average_weight ?? null,
unit: 'gram',
icon: '⚖️',
trend: weightTrend,
status: latestAvg || weights[0] ? 'ok' : 'na',
targetView: 'weightMonitoring',
},
{
key: 'fcr',
title: 'FCR',
value: latest?.fcr ?? null,
unit: '',
icon: '📈',
trend: fcrTrend,
status: latest ? 'ok' : 'na',
targetView: 'fcrDashboard',
},
{
key: 'eef',
title: 'EEF',
value: latest?.eef ?? null,
unit: '',
icon: '🎯',
trend: eefTrend,
status: latest ? 'ok' : 'na',
targetView: 'eefDashboard',
},
];
}, [kpis, countings, weights, karung, latestAvg, selectedCycle]);
if (!selectedCycle) return <EmptyFarm onSettings={() => setView('kandangSettings')} />;
const feedTotal = kpis[0]?.feed_total ?? 0;
const feedWeightKg = feedTotal * 50;
const feedInTotal = karung?.in_total ?? 0;
const feedOutTotal = karung?.out_total ?? 0;
const feedInitialBalance = selectedCycle?.feed_initial_balance ?? 0;
const feedSaldo = feedInTotal - feedTotal - feedOutTotal;
const feedSaldoColor =
feedSaldo > 0 ? 'text-green-600' : feedSaldo < 0 ? 'text-red-600' : 'text-gray-600';
return (
<div>
<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)} (${selectedCycle.status === 'active' ? 'aktif' : 'selesai'})`}
/>
<DashboardReportCtas
cycleClosed={selectedCycle.status === 'closed'}
hasDailySnapshot={false}
hasEndCycleSnapshot={false}
onOpenDaily={() => {
setReportType('daily');
setReportOpen(true);
}}
onOpenEndCycle={() => {
setReportType('end_cycle');
setReportOpen(true);
}}
/>
</div>
{error && (
<div className="mb-4">
<Banner kind="error">{error}</Banner>
</div>
)}
{loading ? (
<Spinner />
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-6">
{cards.map((card) => (
<KpiCard
key={card.key}
title={card.title}
value={card.value}
unit={card.unit}
icon={card.icon}
trend={card.trend}
status={card.status}
onClick={card.targetView ? () => setView(card.targetView as View) : undefined}
/>
))}
</div>
<div className="mt-6 rounded-xl border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 p-6 shadow-sm">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-blue-500 p-3 text-white">
<i className="fa-solid fa-boxes-stacked text-2xl" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-800">Total Pakan</h2>
<p className="text-sm text-gray-600">Kumulatif karung pakan sejak awal siklus</p>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-white p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-boxes-stacked text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Total Pakan</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(feedTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center sm:border-l-2 sm:border-blue-200 sm:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-arrow-down text-green-600" />
<p className="text-sm font-semibold text-gray-700">Total Feed In</p>
</div>
<p className="text-3xl font-bold text-green-600">{formatNumber(feedInTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-arrow-right text-red-600" />
<p className="text-sm font-semibold text-gray-700">Total Feed Out</p>
</div>
<p className="text-3xl font-bold text-red-600">{formatNumber(feedOutTotal)}</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
<div className="text-center lg:border-l-2 lg:border-blue-200 lg:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-box-archive text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Saldo Awal</p>
</div>
<p className="text-3xl font-bold text-blue-600">
{formatNumber(feedInitialBalance)}
</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 border-t border-blue-200 pt-6 sm:grid-cols-2">
<div className="text-center">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-weight-scale text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Total Berat Pakan</p>
</div>
<p className="text-3xl font-bold text-blue-600">{formatNumber(feedWeightKg)}</p>
<p className="mt-1 text-xs text-gray-400">kg</p>
</div>
<div className="text-center sm:border-l-2 sm:border-blue-200 sm:pl-6">
<div className="mb-2 flex items-center justify-center gap-2">
<i className="fa-solid fa-scale-balanced text-blue-600" />
<p className="text-sm font-semibold text-gray-700">Saldo Karung Pakan</p>
</div>
<p className={`text-3xl font-bold ${feedSaldoColor}`}>
{formatNumber(feedSaldo)}
</p>
<p className="mt-1 text-xs text-gray-400">karung</p>
</div>
</div>
</div>
</div>
<section className="mt-8">
<h2 className="text-lg font-bold text-gray-800 mb-3">AI Insight</h2>
{insights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{insights.slice(0, 4).map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">
{item.section}
</p>
<h3 className="font-semibold text-gray-800 mt-1">
{item.alert || 'Insight'}
</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
<DashboardManualInputForm
cycleId={selectedCycle.id}
currentDay={selectedCycle.current_day}
readOnly={selectedCycle.status !== 'active'}
/>
</>
)}
<ReportPreviewOverlay
open={reportOpen}
reportType={reportType}
onClose={() => setReportOpen(false)}
/>
</div>
);
};
export default Dashboard;
+471
View File
@@ -0,0 +1,471 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { api, errorMessage } from '../services/apiClient.ts';
import type { AIInsight, KPI } from '../types/api.ts';
import { useFarm } from '../context/FarmContext.tsx';
import { Banner, EmptyFarm, PageHeader, Spinner } from './ui/Feedback.tsx';
import { formatNumber, formatRatio } from '../utils/format.ts';
type EefRow = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
eef: number | null;
};
type TrendPoint = { day: number; eef: number | null };
type SupportPoint = {
day: number;
chickenLifePercentage: number | null;
averageHarvestBw: number | null;
averageHarvestDay: number | null;
fcr: number | null;
};
const formatThreeDecimal = (value: number | null): string =>
value == null ? 'N/A' : formatRatio(value, 3);
const formatNum = (value: number | null | undefined, maxDecimals = 0): string =>
value == null || !Number.isFinite(Number(value))
? 'N/A'
: formatNumber(Number(value), maxDecimals, 0, true);
const StatCard: React.FC<{ title: string; value: string; unit: string }> = ({
title,
value,
unit,
}) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-500">{title}</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-gray-800">{value}</span>
<span className="text-sm font-semibold text-gray-500">{unit}</span>
</div>
</div>
);
const EefTrendChart: React.FC<{ data: TrendPoint[] }> = ({ data }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik EEF</h2>
<div className="h-80">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data EEF tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) => formatNum(Number(value), 0)}
width={60}
domain={[0, 'dataMax + 20']}
label={{ value: 'EEF', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => [formatThreeDecimal(Number(value)), 'EEF']}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey="eef"
name="EEF"
stroke="#dc2626"
strokeWidth={2.5}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const SupportingMetricChart: React.FC<{
title: string;
data: SupportPoint[];
dataKey: keyof Omit<SupportPoint, 'day'>;
unit: string;
stroke: string;
maximumFractionDigits?: number;
valueFormatter?: (value: number) => string;
}> = ({ title, data, dataKey, unit, stroke, maximumFractionDigits = 0, valueFormatter }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4">{title}</h3>
<div className="h-64">
{data.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Tidak ada data tersedia.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="day" fontSize={12} tick={{ fill: '#6b7280' }} />
<YAxis
fontSize={12}
tick={{ fill: '#6b7280' }}
tickFormatter={(value) =>
valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits)
}
width={70}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #d1d5db',
borderRadius: '8px',
}}
formatter={(value) => {
const formatted = valueFormatter
? valueFormatter(Number(value))
: formatNum(Number(value), maximumFractionDigits);
return [`${formatted}${unit ? ` ${unit}` : ''}`, title];
}}
labelFormatter={(label) => `Hari ${label}`}
/>
<Line
type="monotone"
dataKey={dataKey}
name={title}
stroke={stroke}
strokeWidth={2.5}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</div>
);
const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => {
const { selectedCycle } = useFarm();
const [rows, setRows] = useState<KPI[]>([]);
const [insights, setInsights] = useState<AIInsight[]>([]);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
if (!selectedCycle) return;
setError(null);
try {
const [kpiRows, insightRows] = await Promise.all([
api.kpis.list({ cycle_id: selectedCycle.id }),
api.insights.list({ cycle_id: selectedCycle.id }),
]);
setRows(kpiRows);
setInsights(insightRows);
setLastUpdated(new Date());
} catch (err) {
setError(errorMessage(err));
}
}, [selectedCycle]);
useEffect(() => {
if (!selectedCycle) {
setRows([]);
setInsights([]);
setLastUpdated(null);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
await loadData();
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedCycle, loadData]);
const handleRefresh = async () => {
setRefreshing(true);
try {
await loadData();
} finally {
setRefreshing(false);
}
};
const eefRows = useMemo<EefRow[]>(
() =>
[...rows]
.sort((a, b) => a.age - b.age || a.date.localeCompare(b.date))
.map((row) => ({
day: row.age,
chickenLifePercentage: row.chicken_life_percentage,
averageHarvestBw: row.average_harvest_weight > 0 ? row.average_harvest_weight : null,
averageHarvestDay: row.average_harvest_day > 0 ? row.average_harvest_day : null,
fcr: row.fcr > 0 ? row.fcr : null,
eef: row.eef > 0 ? row.eef : null,
})),
[rows]
);
const trendData = useMemo<TrendPoint[]>(
() => eefRows.map((row) => ({ day: row.day, eef: row.eef })),
[eefRows]
);
const supportData = useMemo<SupportPoint[]>(
() =>
eefRows.map((row) => ({
day: row.day,
chickenLifePercentage: row.chickenLifePercentage,
averageHarvestBw: row.averageHarvestBw,
averageHarvestDay: row.averageHarvestDay,
fcr: row.fcr,
})),
[eefRows]
);
const getLatestValid = <K extends keyof EefRow>(key: K): EefRow[K] | null => {
for (let i = eefRows.length - 1; i >= 0; i--) {
const val = eefRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestEef = getLatestValid('eef');
const eefInsights = useMemo(() => {
const matched = insights.filter((item) => {
const text =
`${item.section ?? ''} ${item.alert ?? ''} ${item.insight_text ?? ''}`.toLowerCase();
return (
text.includes('eef') ||
text.includes('fcr') ||
text.includes('hidup') ||
text.includes('live') ||
text.includes('panen') ||
text.includes('bw')
);
});
return (matched.length > 0 ? matched : insights).slice(0, 4);
}, [insights]);
if (!selectedCycle) return <EmptyFarm onSettings={onSettings} />;
return (
<main className="mx-auto max-w-[1240px] space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<PageHeader
title="EEF"
subtitle="Dashboard European Efficiency Factor berdasarkan data performa produksi IoT."
icon={<i className="fa-solid fa-gauge-high" />}
/>
<button
onClick={handleRefresh}
disabled={refreshing || loading}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-400"
>
<i className={`fa-solid fa-rotate-right ${refreshing || loading ? 'fa-spin' : ''}`} />
<span>{refreshing || loading ? 'Memuat...' : 'Refresh'}</span>
</button>
</div>
{error && (
<div>
<Banner kind="error">{error}</Banner>
</div>
)}
<section>
<h2 className="text-lg font-bold text-gray-800 mb-3">
<i className="fa-solid fa-brain text-red-600 mr-2" />
AI Insight EEF
</h2>
{loading ? (
<Spinner />
) : eefInsights.length === 0 ? (
<Banner>Belum ada insight untuk siklus ini.</Banner>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{eefInsights.map((item) => (
<article
key={item.id}
className="bg-white rounded-xl shadow-sm p-5 border border-gray-100"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">{item.section}</p>
<h3 className="font-semibold text-gray-800 mt-1">{item.alert || 'Insight'}</h3>
</div>
<span className="text-xs text-gray-400">{item.session}</span>
</div>
<p className="text-sm text-gray-600 mt-3 whitespace-pre-wrap">
{item.insight_text}
</p>
</article>
))}
</div>
)}
</section>
{loading ? (
<Spinner />
) : (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-6">
<StatCard title="EEF" value={formatThreeDecimal(latestEef)} unit="" />
<StatCard
title="Persentase Ayam Hidup"
value={formatNum(getLatestValid('chickenLifePercentage'), 3)}
unit="%"
/>
<StatCard
title="BW Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestBw'), 2)}
unit="g"
/>
<StatCard
title="Umur Rata-rata Panen"
value={formatNum(getLatestValid('averageHarvestDay'), 2)}
unit="hari"
/>
<StatCard title="FCR" value={formatThreeDecimal(getLatestValid('fcr'))} unit="" />
</div>
<EefTrendChart data={trendData} />
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 px-6 py-4 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-800">Data Pendukung EEF</h2>
{lastUpdated && (
<p className="text-sm text-gray-500">
Update:{' '}
{lastUpdated.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
<div className="p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Grafik Data Pendukung</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<SupportingMetricChart
title="Persentase Ayam Hidup"
data={supportData}
dataKey="chickenLifePercentage"
unit="%"
stroke="#2563eb"
maximumFractionDigits={3}
/>
<SupportingMetricChart
title="BW Rata-rata Panen"
data={supportData}
dataKey="averageHarvestBw"
unit="g"
stroke="#16a34a"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="Umur Rata-rata Panen"
data={supportData}
dataKey="averageHarvestDay"
unit=""
stroke="#9333ea"
maximumFractionDigits={2}
/>
<SupportingMetricChart
title="FCR"
data={supportData}
dataKey="fcr"
unit=""
stroke="#dc2626"
valueFormatter={(value) => formatThreeDecimal(value)}
/>
</div>
</div>
<div className="border-t border-gray-100">
<div className="px-6 py-4">
<h2 className="text-xl font-bold text-gray-800">Tabel Data Pendukung EEF</h2>
</div>
<div className="overflow-auto max-h-[420px] border-t border-gray-100">
<table className="min-w-full text-sm">
<thead className="bg-gray-700 text-white sticky top-0 z-10">
<tr>
<th className="p-3 text-left font-semibold">Umur</th>
<th className="p-3 text-right font-semibold">Persentase Ayam Hidup</th>
<th className="p-3 text-right font-semibold">BW Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">Umur Rata-rata Panen</th>
<th className="p-3 text-right font-semibold">FCR</th>
<th className="p-3 text-right font-semibold">EEF</th>
</tr>
</thead>
<tbody>
{eefRows.map((row) => (
<tr key={row.day} className="even:bg-gray-50 hover:bg-red-50">
<td className="p-3 font-semibold text-gray-800">Hari {row.day}</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.chickenLifePercentage, 3)}
{row.chickenLifePercentage != null ? '%' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestBw, 2)}
{row.averageHarvestBw != null ? ' g' : ''}
</td>
<td className="p-3 text-right text-gray-700">
{formatNum(row.averageHarvestDay, 2)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.fcr)}
</td>
<td className="p-3 text-right font-mono text-gray-700">
{formatThreeDecimal(row.eef)}
</td>
</tr>
))}
{eefRows.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
Tidak ada data EEF tersedia.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</main>
);
};
export default EefDashboardPage;
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more