first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
coverage
|
||||
.vite
|
||||
backup
|
||||
__pycache__
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
.vite
|
||||
backend/.venv
|
||||
backend/.pytest_cache
|
||||
backend/**/node_modules
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
db.sqlite3
|
||||
data/
|
||||
media/
|
||||
staticfiles/
|
||||
*.egg-info
|
||||
.git
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
db.sqlite3
|
||||
data/
|
||||
media/
|
||||
staticfiles/
|
||||
*.egg-info/
|
||||
.Python
|
||||
@@ -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/`
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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.
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
Whitespace-only changes.
@@ -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)"))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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")
|
||||
]
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 2
|
||||
threads = 2
|
||||
timeout = 120
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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;
|
||||
$$;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in new issue
Block a user