commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env.local
|
||||
.env.*.local
|
||||
dist
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
backend/
|
||||
QUICKSTART.md
|
||||
@@ -0,0 +1,19 @@
|
||||
# Environment Variables for Docker Deployment
|
||||
# Copy this file to .env and set your values
|
||||
|
||||
# PostgreSQL Database Credentials
|
||||
# IMPORTANT: Change these to strong, unique values in production!
|
||||
DB_USER=dashboard_user
|
||||
DB_PASSWORD=change_this_to_a_strong_password
|
||||
DB_NAME=dashboard_db
|
||||
|
||||
# API URL - Leave empty for local development (uses http://localhost:5001/api)
|
||||
# For Docker deployment, this is set automatically to /api
|
||||
VITE_API_URL=
|
||||
|
||||
# LLM Deployment Settings (Ollama / LM Studio)
|
||||
# Default model name to download and pull automatically
|
||||
LLM_MODEL_NAME=deepseek-r1:8b
|
||||
|
||||
# LLM engine connection URL (defaults to http://llm:11434 inside docker)
|
||||
LM_STUDIO_BASE_URL=http://localhost:11434
|
||||
@@ -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,77 @@
|
||||
# GitHub Actions CI/CD
|
||||
|
||||
This directory contains automated workflows for the chicken farm dashboard.
|
||||
|
||||
## Workflows
|
||||
|
||||
### CI Pipeline (`ci.yml`)
|
||||
|
||||
**Triggers:**
|
||||
|
||||
- Push to `main` or `master` branch
|
||||
- Pull requests to `main` or `master` branch
|
||||
|
||||
**What it does:**
|
||||
|
||||
1. ✅ Checks out code
|
||||
2. ✅ Sets up Node.js 18
|
||||
3. ✅ Installs frontend dependencies
|
||||
4. ✅ Runs ESLint (code quality check)
|
||||
5. ✅ Runs TypeScript type check (strict mode)
|
||||
6. ✅ Runs frontend tests
|
||||
7. ✅ Builds frontend (verifies build works)
|
||||
8. ✅ Installs backend dependencies
|
||||
9. ✅ Runs backend tests
|
||||
|
||||
**Duration:** ~3-5 minutes
|
||||
|
||||
**Status:** Tests run with `continue-on-error: true` for gradual adoption
|
||||
|
||||
## How to View Results
|
||||
|
||||
1. Go to your repository on GitHub
|
||||
2. Click "Actions" tab
|
||||
3. See all workflow runs and their status
|
||||
|
||||
## Adding Status Badge to README
|
||||
|
||||
Add this to your main README.md:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Replace `YOUR-USERNAME` and `YOUR-REPO` with your actual GitHub details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**If CI fails:**
|
||||
|
||||
- Check the "Actions" tab for detailed logs
|
||||
- Most common issues:
|
||||
- TypeScript errors (run `npm run type-check` locally)
|
||||
- Linting errors (run `npm run lint` locally)
|
||||
- Build failures (run `npm run build` locally)
|
||||
- Test failures (run `npm test` locally)
|
||||
|
||||
**Local testing before push:**
|
||||
|
||||
```bash
|
||||
# Run the same checks CI will run
|
||||
npm run lint
|
||||
npm run type-check
|
||||
npm run build
|
||||
npm test
|
||||
|
||||
cd backend
|
||||
npm test
|
||||
```
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Optional enhancements you can add later:
|
||||
|
||||
- Deploy to production on successful build
|
||||
- Run tests against test database
|
||||
- Add code coverage reports
|
||||
- Slack/Discord notifications on failures
|
||||
@@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm run test:run
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: npm run test:run
|
||||
continue-on-error: true
|
||||
|
||||
- name: Test Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "✅ CI Pipeline Complete"
|
||||
echo "📦 Frontend built successfully"
|
||||
echo "🧪 Tests executed (check logs for results)"
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Local Docker Compose overrides (ports for dev machine)
|
||||
docker-compose.override.yml
|
||||
|
||||
# Database dumps (sensitive; keep off git)
|
||||
backups/
|
||||
backup_*.sql
|
||||
*.sql.gz
|
||||
*.dump
|
||||
|
||||
# Environment variables (sensitive data)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
**/.env.local
|
||||
|
||||
# Backend
|
||||
backend/node_modules/
|
||||
backend/data/*.db
|
||||
backend/data/*.db-*
|
||||
|
||||
# Keep structure but ignore database files
|
||||
!backend/data/.gitkeep
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
*~
|
||||
|
||||
# Docker volumes
|
||||
docker-compose.override.yml
|
||||
.docker/
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
# Copy dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Pre-download the Hugging Face embedding model during Docker build phase
|
||||
# so it is baked into the image and does not need internet to start
|
||||
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')"
|
||||
|
||||
# Copy source code and vector DB
|
||||
COPY . .
|
||||
|
||||
# Set offline environment variables
|
||||
ENV HF_HUB_OFFLINE=1
|
||||
ENV TRANSFORMERS_OFFLINE=1
|
||||
ENV RAG_PORT=5002
|
||||
|
||||
EXPOSE 5002
|
||||
|
||||
CMD ["python", "rag_service.py"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
import glob
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
INPUT_DIR = os.getenv("INPUT_DIR", "documents")
|
||||
OUTPUT_TXT_DIR = os.getenv("OUTPUT_TXT_DIR", "extracted_txt")
|
||||
|
||||
def extract_docx(file_path):
|
||||
"""Mengekstrak teks dari file .docx dengan menjaga urutan asli paragraf dan tabel."""
|
||||
import docx
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.text.paragraph import Paragraph
|
||||
from docx.table import Table
|
||||
|
||||
doc = docx.Document(file_path)
|
||||
full_text = []
|
||||
|
||||
# Iterasi semua elemen anak di dalam body document untuk menjaga urutan
|
||||
for element in doc.element.body:
|
||||
tag = element.tag
|
||||
if tag.endswith('p'):
|
||||
para = Paragraph(element, doc)
|
||||
if para.text.strip():
|
||||
full_text.append(para.text)
|
||||
elif tag.endswith('tbl'):
|
||||
table = Table(element, doc)
|
||||
table_text = []
|
||||
for row in table.rows:
|
||||
row_text = []
|
||||
for cell in row.cells:
|
||||
text = cell.text.strip()
|
||||
# Hindari duplikasi text sel gabungan (merged cells) secara berturut-turut
|
||||
if not row_text or row_text[-1] != text:
|
||||
row_text.append(text)
|
||||
if row_text:
|
||||
table_text.append(" | ".join(row_text))
|
||||
if table_text:
|
||||
full_text.append("\n".join(table_text))
|
||||
|
||||
return "\n\n".join(full_text)
|
||||
|
||||
def extract_pdf(file_path):
|
||||
"""Mengekstrak teks dari file .pdf halaman demi halaman."""
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(file_path)
|
||||
full_text = []
|
||||
for i, page in enumerate(reader.pages):
|
||||
text = page.extract_text()
|
||||
if text and text.strip():
|
||||
full_text.append(text)
|
||||
return "\n".join(full_text)
|
||||
|
||||
def extract_txt(file_path):
|
||||
"""Membaca file teks dengan encoding UTF-8."""
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return f.read()
|
||||
|
||||
def main():
|
||||
# Pastikan folder input ada
|
||||
if not os.path.exists(INPUT_DIR):
|
||||
print(f"Folder input '{INPUT_DIR}' tidak ditemukan. Membuat folder...")
|
||||
os.makedirs(INPUT_DIR)
|
||||
print(f"Silakan letakkan file dokumen Anda di folder '{INPUT_DIR}' lalu jalankan kembali script ini.")
|
||||
return
|
||||
|
||||
# Buat folder output jika belum ada
|
||||
os.makedirs(OUTPUT_TXT_DIR, exist_ok=True)
|
||||
|
||||
# Cari semua dokumen pendukung
|
||||
supported_extensions = ["*.docx", "*.pdf", "*.txt"]
|
||||
files_to_process = []
|
||||
for ext in supported_extensions:
|
||||
# Cari case-insensitive atau kombinasikan lowercase/uppercase
|
||||
files_to_process.extend(glob.glob(os.path.join(INPUT_DIR, ext)))
|
||||
files_to_process.extend(glob.glob(os.path.join(INPUT_DIR, ext.upper())))
|
||||
|
||||
# Hapus duplikasi jika ada (karena pencarian case-sensitive pada OS tertentu)
|
||||
files_to_process = list(set(files_to_process))
|
||||
|
||||
if not files_to_process:
|
||||
print(f"Tidak ada file .docx, .pdf, atau .txt yang ditemukan di folder '{INPUT_DIR}'.")
|
||||
return
|
||||
|
||||
print(f"Menemukan {len(files_to_process)} file dokumen untuk diekstrak.")
|
||||
|
||||
for file_path in files_to_process:
|
||||
filename = os.path.basename(file_path)
|
||||
base_name, ext = os.path.splitext(filename)
|
||||
output_file_path = os.path.join(OUTPUT_TXT_DIR, f"{base_name}.txt")
|
||||
|
||||
print(f"Mengekstrak: {filename} ... ", end="", flush=True)
|
||||
|
||||
try:
|
||||
ext_lower = ext.lower()
|
||||
if ext_lower == ".docx":
|
||||
text = extract_docx(file_path)
|
||||
elif ext_lower == ".pdf":
|
||||
text = extract_pdf(file_path)
|
||||
elif ext_lower == ".txt":
|
||||
text = extract_txt(file_path)
|
||||
else:
|
||||
print("Format tidak didukung (dilewati)")
|
||||
continue
|
||||
|
||||
# Simpan hasil teks ke file .txt di folder output
|
||||
with open(output_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
print(f"Selesai! Disimpan ke: {output_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"GAGAL! Error: {str(e)}")
|
||||
|
||||
print("\nProses ekstraksi selesai seluruhnya!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,162 @@
|
||||
import os
|
||||
import glob
|
||||
from dotenv import load_dotenv
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# Set offline mode agar sentence-transformers tidak mencoba menghubungi Hugging Face di jaringan on-premise
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
OUTPUT_TXT_DIR = os.getenv("OUTPUT_TXT_DIR", "extracted_txt")
|
||||
CHROMA_DB_DIR = os.getenv("CHROMA_DB_DIR", "chroma_db")
|
||||
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "all-MiniLM-L6-v2")
|
||||
|
||||
def chunk_text(text, chunk_size=800, chunk_overlap=150):
|
||||
"""Memecah teks menjadi chunk berdasarkan paragraf, baris, atau kata."""
|
||||
paragraphs = text.split("\n\n")
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for para in paragraphs:
|
||||
para = para.strip()
|
||||
if not para:
|
||||
continue
|
||||
|
||||
# Jika paragraf itu sendiri lebih besar dari chunk_size, bagi berdasarkan baris
|
||||
if len(para) > chunk_size:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = ""
|
||||
|
||||
lines = para.split("\n")
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if len(line) > chunk_size:
|
||||
# Bagi baris panjang berdasarkan kata
|
||||
words = line.split(" ")
|
||||
temp_chunk = ""
|
||||
for word in words:
|
||||
if len(temp_chunk) + len(word) + 1 > chunk_size:
|
||||
if temp_chunk:
|
||||
chunks.append(temp_chunk)
|
||||
overlap_start = max(0, len(temp_chunk) - chunk_overlap)
|
||||
temp_chunk = temp_chunk[overlap_start:].strip()
|
||||
if temp_chunk:
|
||||
temp_chunk += " " + word
|
||||
else:
|
||||
temp_chunk = word
|
||||
else:
|
||||
if temp_chunk:
|
||||
temp_chunk += " " + word
|
||||
else:
|
||||
temp_chunk = word
|
||||
if temp_chunk:
|
||||
current_chunk = temp_chunk
|
||||
else:
|
||||
if len(current_chunk) + len(line) + 1 > chunk_size:
|
||||
chunks.append(current_chunk)
|
||||
overlap_start = max(0, len(current_chunk) - chunk_overlap)
|
||||
current_chunk = current_chunk[overlap_start:].strip()
|
||||
if current_chunk:
|
||||
current_chunk += "\n" + line
|
||||
else:
|
||||
current_chunk = line
|
||||
else:
|
||||
if current_chunk:
|
||||
current_chunk += "\n" + line
|
||||
else:
|
||||
current_chunk = line
|
||||
else:
|
||||
# Pengelompokan paragraf standar
|
||||
if len(current_chunk) + len(para) + 2 > chunk_size:
|
||||
chunks.append(current_chunk)
|
||||
overlap_start = max(0, len(current_chunk) - chunk_overlap)
|
||||
current_chunk = current_chunk[overlap_start:].strip()
|
||||
if current_chunk:
|
||||
current_chunk += "\n\n" + para
|
||||
else:
|
||||
current_chunk = para
|
||||
else:
|
||||
if current_chunk:
|
||||
current_chunk += "\n\n" + para
|
||||
else:
|
||||
current_chunk = para
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
def main():
|
||||
if not os.path.exists(OUTPUT_TXT_DIR):
|
||||
print(f"Folder teks terekstrak '{OUTPUT_TXT_DIR}' tidak ditemukan. Jalankan extract_text.py terlebih dahulu.")
|
||||
return
|
||||
|
||||
txt_files = glob.glob(os.path.join(OUTPUT_TXT_DIR, "*.txt"))
|
||||
if not txt_files:
|
||||
print(f"Tidak ada file .txt ditemukan di '{OUTPUT_TXT_DIR}'. Jalankan extract_text.py terlebih dahulu.")
|
||||
return
|
||||
|
||||
# Inisialisasi Model Embedding lokal
|
||||
print(f"Memuat model embedding lokal '{EMBEDDING_MODEL_NAME}'...")
|
||||
model = SentenceTransformer(EMBEDDING_MODEL_NAME)
|
||||
print("Model embedding berhasil dimuat.")
|
||||
|
||||
# Inisialisasi Chroma DB client
|
||||
print(f"Menginisialisasi Chroma DB di folder '{CHROMA_DB_DIR}'...")
|
||||
client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
|
||||
|
||||
# Hapus koleksi lama jika ada untuk menghindari duplikasi data lama saat indeks ulang
|
||||
try:
|
||||
client.delete_collection(name="company_sop")
|
||||
print("Koleksi lama 'company_sop' berhasil dihapus untuk indeks ulang.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
collection = client.create_collection(name="company_sop")
|
||||
|
||||
total_chunks = 0
|
||||
|
||||
for file_path in txt_files:
|
||||
filename = os.path.basename(file_path)
|
||||
print(f"\nMemproses chunking & embedding untuk file: {filename}...")
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
|
||||
chunks = chunk_text(text, chunk_size=800, chunk_overlap=150)
|
||||
if not chunks:
|
||||
print(f"File {filename} kosong atau tidak menghasilkan chunk.")
|
||||
continue
|
||||
|
||||
print(f"Menghasilkan {len(chunks)} chunks dari {filename}. Membuat embedding...")
|
||||
|
||||
# Hitung embeddings
|
||||
embeddings = model.encode(chunks)
|
||||
embeddings_list = [emb.tolist() for emb in embeddings]
|
||||
|
||||
# Siapkan metadata dan ID unik untuk Chroma DB
|
||||
metadatas = [{"source": filename, "chunk_index": i} for i in range(len(chunks))]
|
||||
ids = [f"{filename}_chunk_{i}" for i in range(len(chunks))]
|
||||
|
||||
# Tambahkan ke Chroma DB
|
||||
collection.add(
|
||||
documents=chunks,
|
||||
embeddings=embeddings_list,
|
||||
metadatas=metadatas,
|
||||
ids=ids
|
||||
)
|
||||
|
||||
total_chunks += len(chunks)
|
||||
print(f"Berhasil menyimpan {len(chunks)} chunks ke Chroma DB.")
|
||||
|
||||
print(f"\nProses pembuatan database selesai! Total {total_chunks} chunks berhasil disimpan di Chroma DB.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
# Set offline mode agar sentence-transformers tidak mencoba menghubungi Hugging Face di jaringan on-premise
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
CHROMA_DB_DIR = os.getenv("CHROMA_DB_DIR", "chroma_db")
|
||||
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "all-MiniLM-L6-v2")
|
||||
OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "lm-studio")
|
||||
LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "deepseek-r1-distill-qwen-7b")
|
||||
|
||||
def main():
|
||||
# 1. Inisialisasi Database Vektor
|
||||
if not os.path.exists(CHROMA_DB_DIR):
|
||||
print(f"Database Chroma DB di '{CHROMA_DB_DIR}' tidak ditemukan. Silakan jalankan extract_text.py dan populate_db.py terlebih dahulu.")
|
||||
return
|
||||
|
||||
print("Menghubungkan ke Chroma DB...")
|
||||
chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
|
||||
try:
|
||||
collection = chroma_client.get_collection(name="company_sop")
|
||||
except Exception as e:
|
||||
print(f"Koleksi 'company_sop' tidak ditemukan di database. Pastikan populate_db.py sudah dijalankan dengan sukses. Error: {e}")
|
||||
return
|
||||
|
||||
# 2. Inisialisasi Model Embedding lokal
|
||||
print(f"Memuat model embedding lokal '{EMBEDDING_MODEL_NAME}' untuk kueri...")
|
||||
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
|
||||
print("Model embedding berhasil dimuat.")
|
||||
|
||||
# 3. Konfigurasi koneksi LM Studio native v1 API
|
||||
LM_STUDIO_API_URL = os.getenv("LM_STUDIO_API_URL")
|
||||
if not LM_STUDIO_API_URL:
|
||||
openai_base = os.getenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
|
||||
if openai_base.endswith("/v1"):
|
||||
LM_STUDIO_API_URL = openai_base.replace("/v1", "/api/v1/chat")
|
||||
else:
|
||||
LM_STUDIO_API_URL = f"{openai_base.rstrip('/')}/api/v1/chat"
|
||||
|
||||
print(f"Mengonfigurasi koneksi LLM ke native API: {LM_STUDIO_API_URL} (Model: {LLM_MODEL_NAME})...")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(" PIPELINE RAG LOKAL - ASISTEN SOP PERUSAHAAN (DEEPSEEK)")
|
||||
print(" Ketik 'keluar' atau 'exit' untuk menyudahi percakapan.")
|
||||
print("="*60 + "\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
query = input("\nPertanyaan Anda: ").strip()
|
||||
if not query:
|
||||
continue
|
||||
if query.lower() in ["keluar", "exit", "q", "quit"]:
|
||||
print("Sampai jumpa!")
|
||||
break
|
||||
|
||||
print("\n[1/3] Mencari dokumen referensi relevan di database lokal...", end="", flush=True)
|
||||
# Buat embedding kueri
|
||||
query_embedding = embedding_model.encode([query])[0].tolist()
|
||||
|
||||
# Cari kueri di Chroma DB (ambil 6 chunk teratas)
|
||||
results = collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=6
|
||||
)
|
||||
print(" Selesai!")
|
||||
|
||||
retrieved_chunks = results['documents'][0]
|
||||
retrieved_metadatas = results['metadatas'][0]
|
||||
|
||||
if not retrieved_chunks or len(retrieved_chunks) == 0:
|
||||
print("⚠️ Tidak ditemukan referensi dokumen yang cocok dengan pertanyaan Anda.")
|
||||
continue
|
||||
|
||||
# Tampilkan referensi yang ditemukan
|
||||
print("\n[Referensi yang Ditemukan]:")
|
||||
for idx, meta in enumerate(retrieved_metadatas):
|
||||
print(f" - [{idx+1}] File: {meta['source']} (Chunk: {meta['chunk_index']})")
|
||||
|
||||
# 4. Susun Prompt dengan Konteks SOP
|
||||
context = "\n\n---\n\n".join(retrieved_chunks)
|
||||
|
||||
system_prompt = (
|
||||
"Anda adalah asisten AI perusahaan yang profesional. Tugas Anda adalah memberikan jawaban "
|
||||
"yang valid, akurat, dan sesuai dengan Standar Operasional Prosedur (SOP) atau dokumen acuan perusahaan "
|
||||
"yang disediakan di bawah ini.\n"
|
||||
"Patuhi aturan berikut:\n"
|
||||
"1. Jawablah HANYA berdasarkan informasi yang ada dalam dokumen acuan di bawah.\n"
|
||||
"2. Jika jawaban tidak dapat ditemukan di dalam dokumen tersebut secara eksplisit atau logis, katakan dengan sopan "
|
||||
"bahwa 'Maaf, informasi tersebut tidak ditemukan dalam dokumen SOP/acuan perusahaan kami.' Jangan mengarang informasi.\n"
|
||||
"3. Sajikan data dengan valid dan rapi."
|
||||
)
|
||||
|
||||
user_prompt = f"""Dokumen SOP / Acuan Perusahaan:
|
||||
=========================================
|
||||
{context}
|
||||
=========================================
|
||||
|
||||
Pertanyaan Pengguna: {query}
|
||||
|
||||
Jawaban berdasarkan Dokumen Acuan:"""
|
||||
|
||||
print(f"\n[2/3] Menghubungi LLM DeepSeek lokal di {LM_STUDIO_API_URL}...", end="", flush=True)
|
||||
|
||||
# Panggil LM Studio native v1 API
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if OPENAI_API_KEY and OPENAI_API_KEY != "lm-studio":
|
||||
headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
|
||||
|
||||
payload = {
|
||||
"model": LLM_MODEL_NAME,
|
||||
"input": user_prompt,
|
||||
"system_prompt": system_prompt,
|
||||
"temperature": 0.1,
|
||||
"max_output_tokens": 32000
|
||||
}
|
||||
|
||||
response = requests.post(LM_STUDIO_API_URL, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
print(" Selesai!")
|
||||
|
||||
answer = response.json()["response"]
|
||||
|
||||
# Pisahkan proses berpikir (<think>) jika ada (khusus model reasoning seperti DeepSeek R1)
|
||||
import re
|
||||
think_match = re.search(r'<think>(.*?)</think>', answer, re.DOTALL)
|
||||
clean_answer = re.sub(r'<think>.*?</think>', '', answer, flags=re.DOTALL).strip()
|
||||
|
||||
if think_match and think_match.group(1).strip():
|
||||
print(" Selesai!")
|
||||
print("\n[Proses Berpikir DeepSeek]:")
|
||||
print("." * 50)
|
||||
print(think_match.group(1).strip())
|
||||
print("." * 50)
|
||||
else:
|
||||
print(" Selesai!")
|
||||
|
||||
print("\n[3/3] Respon Asisten SOP:")
|
||||
print("-"*50)
|
||||
print(clean_answer)
|
||||
print("-"*50)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Terjadi kesalahan: {e}")
|
||||
print("Harap pastikan server LLM lokal Anda (LM Studio/vLLM/llama.cpp) sedang berjalan dan dapat diakses.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
RAG Microservice untuk CP 707 Knowledge Base
|
||||
Berjalan di port 5002, dipanggil oleh backend Node.js
|
||||
Menggunakan ChromaDB + SentenceTransformers (offline mode)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Set offline agar tidak download dari HuggingFace
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
|
||||
# Load .env dari direktori yang sama dengan script ini
|
||||
script_dir = Path(__file__).parent
|
||||
load_dotenv(script_dir / ".env")
|
||||
|
||||
CHROMA_DB_DIR = str(script_dir / os.getenv("CHROMA_DB_DIR", "chroma_db"))
|
||||
EMBEDDING_MODEL_NAME = os.getenv("EMBEDDING_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||
RAG_PORT = int(os.getenv("RAG_PORT", "5002"))
|
||||
COLLECTION_NAME = "company_sop"
|
||||
|
||||
print(f"[RAG Service] ChromaDB path: {CHROMA_DB_DIR}")
|
||||
print(f"[RAG Service] Embedding model: {EMBEDDING_MODEL_NAME}")
|
||||
|
||||
# Import setelah env set
|
||||
import chromadb
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
import uvicorn
|
||||
|
||||
# ─── Init ChromaDB & Embedding Model ─────────────────────────────────────────
|
||||
print("[RAG Service] Memuat ChromaDB...")
|
||||
try:
|
||||
chroma_client = chromadb.PersistentClient(path=CHROMA_DB_DIR)
|
||||
collection = chroma_client.get_collection(name=COLLECTION_NAME)
|
||||
total_chunks = collection.count()
|
||||
print(f"[RAG Service] ChromaDB loaded. Total chunks: {total_chunks}")
|
||||
except Exception as e:
|
||||
print(f"[RAG Service] ERROR: Gagal load ChromaDB: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[RAG Service] Memuat embedding model '{EMBEDDING_MODEL_NAME}'...")
|
||||
try:
|
||||
embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
|
||||
print("[RAG Service] Embedding model berhasil dimuat.")
|
||||
except Exception as e:
|
||||
print(f"[RAG Service] ERROR: Gagal load embedding model: {e}")
|
||||
print("[RAG Service] Pastikan model sudah didownload. Jalankan populate_db.py terlebih dahulu.")
|
||||
sys.exit(1)
|
||||
|
||||
# ─── FastAPI App ──────────────────────────────────────────────────────────────
|
||||
app = FastAPI(
|
||||
title="CP707 RAG Service",
|
||||
description="Retrieval-Augmented Generation service untuk buku Manajemen Broiler CP 707",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5001", "http://127.0.0.1:5001"],
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─── Request/Response Models ─────────────────────────────────────────────────
|
||||
class QueryRequest(BaseModel):
|
||||
query: str = Field(..., min_length=1, description="Query text untuk mencari chunk CP707 relevan")
|
||||
n_results: int = Field(default=4, ge=1, le=10, description="Jumlah chunk yang dikembalikan")
|
||||
topic: str = Field(default="", description="Topic insight: berat_ayam, fcr, iot_panel, dll")
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
success: bool
|
||||
chunks: list
|
||||
sources: list
|
||||
total_found: int
|
||||
query_used: str
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
total_chunks: int
|
||||
embedding_model: str
|
||||
|
||||
# ─── Topic → Query enhancement mapping ──────────────────────────────────────
|
||||
# Tambahkan keyword relevan per topic agar embedding search lebih tepat sasaran
|
||||
TOPIC_QUERY_HINTS = {
|
||||
"berat_ayam": "berat badan target bobot ADG pertumbuhan standar mingguan ayam broiler",
|
||||
"fcr": "FCR feed conversion ratio konsumsi pakan efisiensi standar broiler",
|
||||
"iot_panel": "suhu kandang kelembapan amonia CO2 ventilasi lingkungan pemeliharaan broiler",
|
||||
"eef": "EEF indeks performa IP efisiensi produksi siklus broiler",
|
||||
"hitung_ayam": "mortalitas deplesi kematian afkir populasi standar toleransi broiler",
|
||||
"hitung_karung": "pakan karung konsumsi harian feed intake standar broiler",
|
||||
}
|
||||
|
||||
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
def health_check():
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
total_chunks=collection.count(),
|
||||
embedding_model=EMBEDDING_MODEL_NAME,
|
||||
)
|
||||
|
||||
@app.post("/query", response_model=QueryResponse)
|
||||
def query_cp707(req: QueryRequest):
|
||||
"""
|
||||
Cari chunk CP707 yang relevan berdasarkan query.
|
||||
Jika topic disediakan, tambahkan hint keyword agar hasil lebih relevan.
|
||||
"""
|
||||
enhanced_query = req.query
|
||||
if req.topic and req.topic in TOPIC_QUERY_HINTS:
|
||||
enhanced_query = f"{req.query} {TOPIC_QUERY_HINTS[req.topic]}"
|
||||
|
||||
try:
|
||||
query_embedding = embedding_model.encode([enhanced_query])[0].tolist()
|
||||
|
||||
results = collection.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=min(req.n_results, collection.count()),
|
||||
)
|
||||
|
||||
chunks = results["documents"][0] if results["documents"] else []
|
||||
metadatas = results["metadatas"][0] if results["metadatas"] else []
|
||||
|
||||
sources = [
|
||||
f"{m.get('source', 'unknown')} (chunk {m.get('chunk_index', '?')})"
|
||||
for m in metadatas
|
||||
]
|
||||
|
||||
return QueryResponse(
|
||||
success=True,
|
||||
chunks=chunks,
|
||||
sources=sources,
|
||||
total_found=len(chunks),
|
||||
query_used=enhanced_query,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"RAG query error: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"[RAG Service] Starting on http://0.0.0.0:{RAG_PORT}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=RAG_PORT, log_level="warning")
|
||||
@@ -0,0 +1,8 @@
|
||||
chromadb
|
||||
sentence-transformers
|
||||
python-docx
|
||||
pypdf
|
||||
python-dotenv
|
||||
openai
|
||||
fastapi
|
||||
uvicorn
|
||||
+1203
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
# Chicken Counting API Documentation
|
||||
|
||||
**Version:** 2.0
|
||||
**Last Updated:** March 5, 2026
|
||||
**Base URL:** `http://103.215.13.55:5001/api`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Chicken Counting API allows external systems to submit chicken population counts per kandang (coop) per date. The API uses a simple upsert mechanism — calling the endpoint multiple times with the same date and kandang will replace the previous record with the latest data.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests require an API key via the `X-API-Key` header.
|
||||
|
||||
```http
|
||||
X-API-Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463
|
||||
```
|
||||
|
||||
| Header | Value |
|
||||
| ----------- | ------------------------------------------------------ |
|
||||
| `X-API-Key` | `cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463` |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### Submit / Update Chicken Count
|
||||
|
||||
**Endpoint:** `POST /api/chicken-counting`
|
||||
|
||||
**Headers:**
|
||||
|
||||
```http
|
||||
Content-Type: application/json
|
||||
X-API-Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"kandang": "kandang-atas",
|
||||
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
|
||||
"total_count": 5612
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `date` | string | Date in `YYYY-MM-DD` format |
|
||||
| `kandang` | string | Slug of the kandang name (e.g. `kandang-atas`, `kandang-bawah`). Matched against the kandangs table by normalizing the name: lowercase + spaces replaced with dashes. |
|
||||
| `total_count` | integer | Chicken count (must be >= 0) |
|
||||
|
||||
**Optional Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------ | --------------------------- |
|
||||
| `filename` | string | Source video/image filename |
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"date": "2026-03-01",
|
||||
"kandangId": 1,
|
||||
"kandangName": "Kandang Atas",
|
||||
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
|
||||
"totalCount": 5612,
|
||||
"createdAt": "2026-03-01T14:59:38.000Z",
|
||||
"updatedAt": "2026-03-01T14:59:38.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Upsert Behavior:**
|
||||
|
||||
If a record with the same `date` + `kandang` already exists, the `total_count` and `filename` will be updated. The `createdAt` remains unchanged while `updatedAt` reflects the latest call.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
All error responses follow this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Human-readable error message"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
| Status | Error | Description |
|
||||
| ------ | ----------------------- | ----------------------------------------------------------------- |
|
||||
| 401 | API key required | `X-API-Key` header not provided |
|
||||
| 401 | Invalid API key | API key not found or incorrect |
|
||||
| 400 | Missing required fields | `date`, `kandang`, or `total_count` not provided |
|
||||
| 400 | Kandang not found | The `kandang` slug doesn't match any kandang name in the database |
|
||||
| 400 | Invalid date format | Must be `YYYY-MM-DD` |
|
||||
| 400 | Invalid total_count | Must be a non-negative number |
|
||||
| 500 | Internal server error | Server error |
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X POST http://103.215.13.55:5001/api/chicken-counting \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463" \
|
||||
-d '{
|
||||
"date": "2026-03-01",
|
||||
"kandang": "kandang-atas",
|
||||
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
|
||||
"total_count": 5612
|
||||
}'
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://103.215.13.55:5001/api"
|
||||
|
||||
API_KEY = "cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463"
|
||||
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/chicken-counting",
|
||||
headers={"X-API-Key": API_KEY},
|
||||
json={
|
||||
"date": "2026-03-01",
|
||||
"kandang": "kandang-atas",
|
||||
"filename": "farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4",
|
||||
"total_count": 5612
|
||||
}
|
||||
)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const API_KEY = 'cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463';
|
||||
|
||||
const response = await fetch('http://103.215.13.55:5001/api/chicken-counting', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
date: '2026-03-01',
|
||||
kandang: 'kandang-atas',
|
||||
filename: 'farm_sukawarna_5b_ch1_main_20260301145938_20260301151559.mp4',
|
||||
total_count: 5612,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Use `result` here.
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import AppContent from './components/AppContent.tsx';
|
||||
import { AppContextProvider } from './context/AppContext.tsx';
|
||||
import { Kandang } from './types/counting.ts';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
|
||||
const [kandangs, setKandangs] = useState<Kandang[]>([]);
|
||||
|
||||
// This will be populated by AppContent once cycle data is available
|
||||
// For now, we'll pass the state and setter to AppContent
|
||||
return (
|
||||
<AppContextProvider selectedKandangId={selectedKandangId}>
|
||||
<AppContent
|
||||
selectedKandangId={selectedKandangId}
|
||||
setSelectedKandangId={setSelectedKandangId}
|
||||
kandangs={kandangs}
|
||||
setKandangs={setKandangs}
|
||||
/>
|
||||
</AppContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,996 @@
|
||||
# Data Source Mapping - Dashboard Solusi AI Peternakan Ayam
|
||||
|
||||
This document maps every page, section, graph, and box to their respective data sources (internal backend vs external API).
|
||||
|
||||
## Base URLs
|
||||
|
||||
- **External API (CPSP)**: `https://dashboard.cpsp.id`
|
||||
- **Internal Backend**: `http://localhost:5001/api` (or `VITE_API_URL` env variable)
|
||||
|
||||
---
|
||||
|
||||
## 1. Main Dashboard (`/`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 1.1 Filter Dropdowns Section
|
||||
|
||||
- **Component**: `FilterDropdown` (Area & Kandang)
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/kandangs/by-cycle/{cycleId}`
|
||||
- **Usage**: Load kandang options for dropdown filter
|
||||
|
||||
#### 1.2 KPI Cards (6 cards)
|
||||
|
||||
- **Component**: `KpiCard`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/cage/9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed`
|
||||
- **Data Path**: `response.data.kpi`
|
||||
- **KPIs**:
|
||||
1. Rata-rata Bobot (Average Weight)
|
||||
2. Total Populasi (Total Population)
|
||||
3. FCR (Feed Conversion Ratio)
|
||||
4. Deplesi (Depletion/Mortality Rate)
|
||||
5. Mortalitas (Mortality Count)
|
||||
6. Umur (Age/Days)
|
||||
|
||||
#### 1.3 Chicken Counting Dashboard
|
||||
|
||||
- **Component**: `ChickenCountingDashboard`
|
||||
- **Data Sources**: Multiple
|
||||
|
||||
##### 1.3.1 Counting Stats Cards
|
||||
|
||||
- **Component**: `CountingStats`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
|
||||
- **Metrics**:
|
||||
- Total Count (today)
|
||||
- Average Count (period)
|
||||
- Change from yesterday
|
||||
- Data points count
|
||||
|
||||
##### 1.3.2 Camera Count Cards (Multiple cameras)
|
||||
|
||||
- **Component**: `CameraCountCard`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
|
||||
- **Filtered by**: Camera name
|
||||
- **Displays**: Latest count per camera
|
||||
|
||||
##### 1.3.3 Population Trend Chart
|
||||
|
||||
- **Component**: `SimplePopulationChart`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
|
||||
- **Chart Type**: Line chart showing population over time
|
||||
- **Aggregation**: Sum of all cameras per day
|
||||
|
||||
##### 1.3.4 Location Count Breakdown
|
||||
|
||||
- **Component**: `LocationCountBreakdown`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}`
|
||||
- **Displays**: Breakdown by kandang location
|
||||
|
||||
#### 1.4 Daily Weight Gain Chart
|
||||
|
||||
- **Component**: `DailyWeightGainChart`
|
||||
- **Data Source**: Multiple (External API + Internal Backend)
|
||||
- **Weight Data API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=range&start_date={start}&end_date={end}`
|
||||
- **FCR Data API**: `GET https://dashboard.cpsp.id/api/data-support/fcrtable/{dow}/{population}/{day}/`
|
||||
- **Mortality Data**: Internal Backend - `GET /api/mortality/{cycleId}`
|
||||
- **Chart Metrics**:
|
||||
- Actual Weight (kg)
|
||||
- Standard Weight (kg)
|
||||
- Daily Gain (g)
|
||||
- ADG (Average Daily Gain)
|
||||
- FCR
|
||||
- IP (Performance Index)
|
||||
- **Caching**: 1-hour cache for weight distribution, 24-hour cache for FCR
|
||||
|
||||
---
|
||||
|
||||
## 2. Weight Monitoring Page (`/weight-monitoring`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 2.1 Weight Stats Cards
|
||||
|
||||
- **Component**: `WeightStats`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=yesterday`
|
||||
- **Metrics**:
|
||||
- Average Weight
|
||||
- Uniformity %
|
||||
- Standard Deviation
|
||||
- Min/Max Weight
|
||||
- Total Predictions
|
||||
- Chicken Count
|
||||
|
||||
#### 2.2 Weight Distribution Chart
|
||||
|
||||
- **Component**: `WeightDistributionChart`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=range&start_date={start}&end_date={end}`
|
||||
- **Chart Type**: Histogram/Bar chart
|
||||
- **Displays**: Weight range distribution with frequency
|
||||
- **Caching**: 1-hour cache grouped by 14-day periods
|
||||
|
||||
#### 2.3 Weight Trend Chart
|
||||
|
||||
- **Component**: `WeightTrendChart`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=range&start_date={start}&end_date={end}`
|
||||
- **Chart Type**: Line chart
|
||||
- **Metrics**: Average weight over time with standard weight comparison
|
||||
|
||||
#### 2.4 Combined Weight Trend Chart
|
||||
|
||||
- **Component**: `CombinedWeightTrendChart`
|
||||
- **Data Source**: Multiple (External + Internal)
|
||||
- **Weight API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/`
|
||||
- **Mortality API**: Internal Backend - `GET /api/mortality/{cycleId}`
|
||||
- **Chart Type**: Multi-line chart with dual Y-axis
|
||||
- **Metrics**: Weight trend + mortality overlay
|
||||
|
||||
#### 2.5 Weight Analysis
|
||||
|
||||
- **Component**: `WeightAnalysis`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/distribusi_bobot/?mode=yesterday`
|
||||
- **Analysis Includes**:
|
||||
- Growth rate calculation
|
||||
- Uniformity assessment
|
||||
- Performance indicators
|
||||
- Alerts and recommendations
|
||||
|
||||
#### 2.6 Sensor Weight Summary
|
||||
|
||||
- **Component**: `SensorWeightSummary`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/summary?start_date={date}&end_date={date}`
|
||||
- **Displays**: Per-device weight summary table
|
||||
- **Metrics per device**:
|
||||
- Device name
|
||||
- Total weight (prediction & actual)
|
||||
- Average weight (prediction & actual)
|
||||
- Chicken count
|
||||
|
||||
#### 2.7 Live Scale View
|
||||
|
||||
- **Component**: `LiveScaleView`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range?start_date={start}&end_date={end}`
|
||||
- **Displays**: Real-time scale readings
|
||||
- **Updates**: Periodic polling
|
||||
|
||||
---
|
||||
|
||||
## 3. Multi-Coop Weight Comparison Page (`/multi-coop-weight`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 3.1 Multi-Coop Analysis
|
||||
|
||||
- **Component**: `MultiCoopAnalysis`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/cage/9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed`
|
||||
- **Data Path**: `response.data.kpi` (multi-coop structure)
|
||||
- **Metrics per coop**:
|
||||
- Average Weight
|
||||
- Population
|
||||
- FCR
|
||||
- Mortality
|
||||
- Growth rate
|
||||
- **Chart Type**: Comparison bar charts
|
||||
|
||||
#### 3.2 Comparison Analysis
|
||||
|
||||
- **Component**: `ComparisonAnalysis`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **Same API as above**
|
||||
- **Analysis**: Statistical comparison between coops
|
||||
- **Displays**:
|
||||
- Best/worst performers
|
||||
- Performance gaps
|
||||
- Recommendations
|
||||
|
||||
#### 3.3 Device Weight Comparison Chart
|
||||
|
||||
- **Component**: `DeviceWeightComparisonChart`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/summary`
|
||||
- **Chart Type**: Bar chart comparing devices
|
||||
- **Metrics**: Average weight per device/coop
|
||||
|
||||
---
|
||||
|
||||
## 4. Feed Sack Counting Page (`/feed-sack-counting`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 4.1 Feed Saldo Card (Balance Summary)
|
||||
|
||||
- **Component**: `FeedSaldoCard`
|
||||
- **Data Sources**: Multiple (External + Internal)
|
||||
- **Feed Sack API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/{cameraName}/`
|
||||
- **Manual Entries API**: Internal Backend - `GET /api/manual-feed-sack?startDate={start}&endDate={end}`
|
||||
- **Metrics**:
|
||||
- Opening balance (Saldo Awal)
|
||||
- Inbound sacks (Karung Masuk)
|
||||
- Usage (Pemakaian)
|
||||
- Closing balance (Saldo Akhir)
|
||||
|
||||
#### 4.2 Feed Stats Cards
|
||||
|
||||
- **Component**: `FeedStats`
|
||||
- **Data Source**: External API + Internal Backend
|
||||
- **External API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
|
||||
- **Internal API**: `GET /api/manual-feed-sack`
|
||||
- **Metrics**:
|
||||
- Total inbound (today/period)
|
||||
- Total usage (today/period)
|
||||
- Average daily usage
|
||||
- Days of supply remaining
|
||||
|
||||
#### 4.3 Feed Trend Chart
|
||||
|
||||
- **Component**: `FeedTrendChart`
|
||||
- **Data Source**: External API + Internal Backend (merged)
|
||||
- **External API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
|
||||
- **Internal API**: `GET /api/manual-feed-sack`
|
||||
- **Chart Type**: Multi-line chart
|
||||
- **Lines**: Inbound, Usage, Balance over time
|
||||
|
||||
#### 4.4 Feed Activity Log
|
||||
|
||||
- **Component**: `FeedActivityLog`
|
||||
- **Data Source**: External API + Internal Backend (merged)
|
||||
- **External API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
|
||||
- **Internal API**: `GET /api/manual-feed-sack`
|
||||
- **Displays**: Timestamped feed sack transactions
|
||||
- **Types**: Inbound, Usage, Outbound, Manual entries
|
||||
|
||||
#### 4.5 Feed Daily Summary Table
|
||||
|
||||
- **Component**: `FeedDailySummaryTable`
|
||||
- **Data Source**: External API + Internal Backend
|
||||
- **Aggregation**: Daily totals by camera/location
|
||||
- **Editable**: Manual entry corrections via Internal Backend
|
||||
- **API for edits**: `PUT /api/manual-feed-sack`
|
||||
|
||||
#### 4.6 Feed Comparison Charts
|
||||
|
||||
- **Component**: `FeedSackComparisonCharts`
|
||||
- **Data Source**: External API + Internal Backend
|
||||
- **Chart Types**:
|
||||
- Camera comparison bar chart
|
||||
- Location comparison bar chart
|
||||
- Time-series comparison
|
||||
|
||||
#### 4.7 Live Feed View
|
||||
|
||||
- **Component**: `LiveFeedView`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/counting/sacks/{siteId}/`
|
||||
- **Updates**: Real-time polling
|
||||
- **Displays**: Latest camera readings
|
||||
|
||||
#### 4.8 Column Configuration
|
||||
|
||||
- **Component**: `ColumnConfigModal`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API GET**: `GET /api/feed-sack-column-config`
|
||||
- **API PUT**: `PUT /api/feed-sack-column-config`
|
||||
- **Purpose**: Save user's column visibility preferences
|
||||
|
||||
---
|
||||
|
||||
## 5. Chicken Counting Page (`/chicken-counting`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 5.1 Counting Stats
|
||||
|
||||
- **Component**: `CountingStats`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
|
||||
- **Metrics**: Same as dashboard section
|
||||
|
||||
#### 5.2 Chicken Count Trend Chart
|
||||
|
||||
- **Component**: `ChickenCountTrendChart`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting?startDate={start}&endDate={end}&kandangId={id}`
|
||||
- **Chart Type**: Line chart with area fill
|
||||
- **Metrics**: Daily population trend
|
||||
|
||||
#### 5.3 Combined Trend Chart (Count + Mortality)
|
||||
|
||||
- **Component**: `CombinedTrendChart`
|
||||
- **Data Source**: Multiple Internal Backend
|
||||
- **Counting API**: `GET /api/chicken-counting`
|
||||
- **Mortality API**: `GET /api/mortality/{cycleId}`
|
||||
- **Chart Type**: Multi-line with dual Y-axis
|
||||
- **Displays**: Population vs mortality correlation
|
||||
|
||||
#### 5.4 Population Trend Chart
|
||||
|
||||
- **Component**: `PopulationTrendChart`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting`
|
||||
- **Chart Type**: Area chart
|
||||
- **Shows**: Population change over cycle
|
||||
|
||||
#### 5.5 Live Count View
|
||||
|
||||
- **Component**: `LiveCountView`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/chicken-counting`
|
||||
- **Updates**: Periodic polling for latest counts
|
||||
|
||||
#### 5.6 Kandang Management
|
||||
|
||||
- **Component**: `KandangManagementPage`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API GET**: `GET /api/kandangs`
|
||||
- **API POST**: `POST /api/kandangs`
|
||||
- **API PUT**: `PUT /api/kandangs/{id}`
|
||||
- **API DELETE**: `DELETE /api/kandangs/{id}`
|
||||
- **DOC In Count**: `PUT /api/kandangs/{kandangId}/cycle/{cycleId}`
|
||||
|
||||
#### 5.7 Data Management
|
||||
|
||||
- **Component**: `DataManagementPage`
|
||||
- **Data Source**: Internal Backend
|
||||
- **Submit Count**: `POST /api/chicken-counting?source=manual`
|
||||
- **Requires**: X-API-Key header
|
||||
|
||||
---
|
||||
|
||||
## 6. IoT Panel / Environment Monitoring (`/iot-panel`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 6.1 IoT Panel Environment Column
|
||||
|
||||
- **Component**: `IotPanelEnvironmentColumn`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **Latest Sensor API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/latest/`
|
||||
- **Range API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/range/?start_date={start}&end_date={end}`
|
||||
- **Metrics**:
|
||||
- Temperature (Suhu)
|
||||
- Humidity (Kelembapan)
|
||||
- Ammonia (Amonia)
|
||||
- **Aggregation**: 5-minute intervals, last 12 hours
|
||||
- **Filter**: Removes sensor failures (zero values)
|
||||
|
||||
#### 6.2 Environment Chart
|
||||
|
||||
- **Component**: `EnvironmentChart`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/range/`
|
||||
- **Chart Type**: Multi-line chart
|
||||
- **Lines**: Temperature, Humidity, Ammonia
|
||||
- **Time Range**: Last 12 hours (filtered)
|
||||
|
||||
#### 6.3 IoT Panel Devices Column
|
||||
|
||||
- **Component**: `IotPanelDevicesColumn`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/cage_sites/get_all/`
|
||||
- **Displays**:
|
||||
- Device list (scales, sensors)
|
||||
- Device status
|
||||
- Last reading timestamp
|
||||
|
||||
#### 6.4 Activity Log Panel
|
||||
|
||||
- **Component**: `ActivityLogPanel`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/log-act/last/?limit={limit}`
|
||||
- **Displays**: Recent IoT system activities
|
||||
- **Log Types**: Device actions, alerts, status changes
|
||||
|
||||
#### 6.5 IoT Sensor History Modal
|
||||
|
||||
- **Component**: `IotSensorHistoryModal`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/iot/flocks/sensors/range/`
|
||||
- **Chart Type**: Historical line chart
|
||||
- **Date Range**: User-selectable
|
||||
|
||||
---
|
||||
|
||||
## 7. Manual Weighing Page (`/manual-weighing`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 7.1 Manual Weighing Stats
|
||||
|
||||
- **Component**: `ManualWeighingStats`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
|
||||
- **Metrics**:
|
||||
- Total manual entries
|
||||
- Average weight
|
||||
- Sample size
|
||||
- Last weighing date
|
||||
|
||||
#### 7.2 Manual Weighing Trends
|
||||
|
||||
- **Component**: `ManualWeighingTrends`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
|
||||
- **Chart Type**: Line chart
|
||||
- **Displays**: Manual weighing data trend
|
||||
|
||||
#### 7.3 Manual Weighing Analysis
|
||||
|
||||
- **Component**: `ManualWeighingAnalysis`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
|
||||
- **Analysis**:
|
||||
- Comparison with sensor data
|
||||
- Variance analysis
|
||||
- Accuracy metrics
|
||||
|
||||
#### 7.4 Weighing Record Cards
|
||||
|
||||
- **Component**: `WeighingRecordCard`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/scales/predictions/range`
|
||||
- **Displays**: Individual manual weighing records
|
||||
|
||||
#### 7.5 AI Weighing Analysis Modal
|
||||
|
||||
- **Component**: `AiWeighingAnalysisModal`
|
||||
- **Data Source**: Mock/Client-side AI
|
||||
- **Note**: AI analysis is currently client-side processing
|
||||
|
||||
---
|
||||
|
||||
## 8. Cycle Management (`/cycle-timeline`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 8.1 Cycle Timeline
|
||||
|
||||
- **Component**: `CycleTimeline`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/cycles`
|
||||
- **Displays**:
|
||||
- Cycle start/end dates
|
||||
- Current day
|
||||
- Total days
|
||||
- Progress bar
|
||||
|
||||
#### 8.2 Cycle Create Modal
|
||||
|
||||
- **Component**: `CycleCreateModal`
|
||||
- **Data Source**: Internal Backend
|
||||
- **API POST**: `POST /api/cycles`
|
||||
- **Fields**:
|
||||
- Start date
|
||||
- End date
|
||||
- Total days
|
||||
- Chick-in weight
|
||||
- DOC in count
|
||||
- Status
|
||||
|
||||
#### 8.3 Active Cycle Info
|
||||
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/cycles/active`
|
||||
- **Used Throughout App**: Context provider
|
||||
|
||||
#### 8.4 Cycle Update
|
||||
|
||||
- **Data Source**: Internal Backend
|
||||
- **API PUT**: `PUT /api/cycles/{id}`
|
||||
|
||||
---
|
||||
|
||||
## 9. Mortality Tracking (Embedded in multiple pages)
|
||||
|
||||
### Components
|
||||
|
||||
#### 9.1 Mortality Records Table
|
||||
|
||||
- **Data Source**: Internal Backend
|
||||
- **API GET**: `GET /api/mortality/{cycleId}`
|
||||
- **API GET (by Kandang)**: `GET /api/mortality/{cycleId}?kandangId={id}`
|
||||
- **API GET (Totals)**: `GET /api/mortality/{cycleId}/totals`
|
||||
- **Displays**:
|
||||
- Daily mortality count
|
||||
- Chicken count
|
||||
- Panen (harvest)
|
||||
- Afkir (culling)
|
||||
- Keterangan (notes)
|
||||
|
||||
#### 9.2 Mortality Update
|
||||
|
||||
- **Data Source**: Internal Backend
|
||||
- **API PUT**: `PUT /api/mortality/{cycleId}/{day}`
|
||||
- **Body**: mortalityCount, chickenCount, kandangId, panen, keterangan, afkir
|
||||
|
||||
#### 9.3 Mortality Delete
|
||||
|
||||
- **Data Source**: Internal Backend
|
||||
- **API DELETE**: `DELETE /api/mortality/{cycleId}/{day}`
|
||||
|
||||
---
|
||||
|
||||
## 10. FCR Calculation (Multiple Pages)
|
||||
|
||||
### Component
|
||||
|
||||
#### 10.1 FCR Comparison Chart
|
||||
|
||||
- **Component**: `FcrComparisonChart`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/data-support/fcrtable/{dow}/{population}/{day}/`
|
||||
- **Parameters**:
|
||||
- `dow`: Day of week (strain type)
|
||||
- `population`: Initial population
|
||||
- `day`: Current day in cycle
|
||||
- **Caching**: 24-hour localStorage cache
|
||||
- **Batch Fetch**: Can fetch multiple days at once
|
||||
- **Metrics**:
|
||||
- Weight
|
||||
- ADG (Average Daily Gain)
|
||||
- Daily Gain
|
||||
- Daily Feed
|
||||
- Feeds Use
|
||||
- FCR
|
||||
- Total Feed
|
||||
- Bags Feed
|
||||
|
||||
---
|
||||
|
||||
## 11. Cobb Standard Reference (`/cobb-standard`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 11.1 Weight Standard Table
|
||||
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/data-support/fcrtable/{dow}/{population}/{day}/`
|
||||
- **Display**: Cobb standard weight by day
|
||||
- **Comparison**: Actual vs standard
|
||||
|
||||
#### 11.2 Performance Index
|
||||
|
||||
- **Data Source**: Multiple (External + Internal)
|
||||
- **Weight**: External API
|
||||
- **Mortality**: Internal Backend
|
||||
- **Calculation**: Client-side based on fetched data
|
||||
|
||||
---
|
||||
|
||||
## 12. Location/Cage Management (`/location-management`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 12.1 Location Structure
|
||||
|
||||
- **Component**: `StructureColumn`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/cage_sites/get_all/`
|
||||
- **Structure**: Site → Scales → Devices
|
||||
- **Transforms To**: Location → Coop → Floor
|
||||
|
||||
#### 12.2 Floor Details
|
||||
|
||||
- **Component**: `FloorDetails`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: Same as above
|
||||
- **Displays**: Device-level details
|
||||
|
||||
---
|
||||
|
||||
## 13. Vet Visit Page (`/vet-visit`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 13.1 Vet Visit Stats
|
||||
|
||||
- **Component**: `VetVisitStats`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Internal Backend endpoint needed
|
||||
|
||||
#### 13.2 Upcoming Visits
|
||||
|
||||
- **Component**: `UpcomingVisits`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 13.3 Visit Log Table
|
||||
|
||||
- **Component**: `VisitLogTable`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 13.4 Visit Notes Modal
|
||||
|
||||
- **Component**: `VisitNotesModal`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 14. Behavior Analysis (`/behavior-analysis`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 14.1 Behavior Summary
|
||||
|
||||
- **Component**: `BehaviorSummary`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Computer vision API needed
|
||||
|
||||
#### 14.2 Behavior Trend Chart
|
||||
|
||||
- **Component**: `BehaviorTrendChart`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 14.3 Live Behavior View
|
||||
|
||||
- **Component**: `LiveBehaviorView`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future**: Real-time video stream analysis
|
||||
|
||||
#### 14.4 Behavior Anomaly Log
|
||||
|
||||
- **Component**: `BehaviorAnomalyLog`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 15. Feed Scheduling (`/feed-scheduling`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 15.1 Schedule Overview
|
||||
|
||||
- **Component**: `ScheduleOverview`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Internal Backend scheduling service
|
||||
|
||||
#### 15.2 Daily Schedule Timeline
|
||||
|
||||
- **Component**: `DailyScheduleTimeline`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 15.3 Consumption Pattern Chart
|
||||
|
||||
- **Component**: `ConsumptionPatternChart`
|
||||
- **Data Source**: External API (Feed Sack) + Mock
|
||||
- **Historical Data**: `GET https://dashboard.cpsp.id/api/counting/sacks/`
|
||||
- **Schedule Data**: Mock (to be implemented)
|
||||
|
||||
#### 15.4 Influencing Factors
|
||||
|
||||
- **Component**: `InfluencingFactors`
|
||||
- **Data Source**: Multiple (Environment + Weight + Mock)
|
||||
- **Environment**: External API
|
||||
- **Weight**: External API
|
||||
- **AI Factors**: Mock
|
||||
|
||||
#### 15.5 Daily Events List
|
||||
|
||||
- **Component**: `DailyEventsList`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 16. Feed Wastage (`/feed-wastage`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 16.1 Wastage Stats
|
||||
|
||||
- **Component**: `WastageStats`
|
||||
- **Data Source**: Calculated from Feed Sack API
|
||||
- **Feed Data**: External API - Feed Sack Counting
|
||||
- **Calculation**: Expected vs Actual usage
|
||||
|
||||
#### 16.2 Live Wastage View
|
||||
|
||||
- **Component**: `LiveWastageView`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future**: Computer vision API for wastage detection
|
||||
|
||||
#### 16.3 Wastage Hotspot Map
|
||||
|
||||
- **Component**: `WastageHotspotMap`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 16.4 Wastage Alert Log
|
||||
|
||||
- **Component**: `WastageAlertLog`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 17. Alerts & Notifications (`/alerts`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 17.1 Alert Feed
|
||||
|
||||
- **Component**: `AlertFeed`
|
||||
- **Data Source**: Multiple sources aggregated
|
||||
- **Weight Alerts**: External API (Weight Distribution)
|
||||
- **Environment Alerts**: External API (IoT Sensors)
|
||||
- **Mortality Alerts**: Internal Backend
|
||||
- **Feed Alerts**: External API (Feed Sack)
|
||||
- **System Alerts**: External API (IoT Activity Logs)
|
||||
|
||||
#### 17.2 Weight Alerts Log
|
||||
|
||||
- **Component**: `WeightAlertsLog`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **Based on**: Weight distribution statistics
|
||||
- **Triggers**: Uniformity < 80%, Std Dev > threshold
|
||||
|
||||
---
|
||||
|
||||
## 18. IoT Device Management (`/iot-devices`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 18.1 IoT Device Dashboard
|
||||
|
||||
- **Component**: `IotDeviceDashboard`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: `GET https://dashboard.cpsp.id/api/cage_sites/get_all/`
|
||||
- **Displays**: All IoT devices with status
|
||||
|
||||
#### 18.2 IoT Device Stats
|
||||
|
||||
- **Component**: `IotDeviceStats`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **Metrics**:
|
||||
- Total devices
|
||||
- Active devices
|
||||
- Offline devices
|
||||
- Last sync time
|
||||
|
||||
#### 18.3 IoT Device Card
|
||||
|
||||
- **Component**: `IotDeviceCard`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **API**: Same as above
|
||||
- **Displays per device**:
|
||||
- Device name
|
||||
- Status
|
||||
- Last reading
|
||||
- Battery level (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## 19. Camera Management (`/cameras`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 19.1 Camera Dashboard
|
||||
|
||||
- **Component**: `CameraDashboard`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **Feed Sack Cameras**: `GET https://dashboard.cpsp.id/api/counting/sacks/`
|
||||
- **Available Cameras**: Extracted from API response
|
||||
|
||||
#### 19.2 Camera Card
|
||||
|
||||
- **Component**: `CameraCard`
|
||||
- **Data Source**: External API (CPSP)
|
||||
- **Displays per camera**:
|
||||
- Camera name
|
||||
- Location
|
||||
- Status
|
||||
- Last capture time
|
||||
- Latest count
|
||||
|
||||
---
|
||||
|
||||
## 20. Network Management (`/network`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 20.1 Network Stats
|
||||
|
||||
- **Component**: `NetworkStats`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Network monitoring service
|
||||
|
||||
#### 20.2 Network Device Dashboard
|
||||
|
||||
- **Component**: `NetworkDeviceDashboard`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 20.3 Bandwidth Chart
|
||||
|
||||
- **Component**: `BandwidthChart`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 21. Batch Processing (`/batch-process`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 21.1 Batch Stats
|
||||
|
||||
- **Component**: `BatchStats`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Batch processing service
|
||||
|
||||
#### 21.2 Service Monitor
|
||||
|
||||
- **Component**: `ServiceMonitor`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 22. OCR Processing (`/ocr`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 22.1 OCR Stats
|
||||
|
||||
- **Component**: `OcrStats`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: OCR service
|
||||
|
||||
#### 22.2 OCR Upload
|
||||
|
||||
- **Component**: `OcrUpload`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 22.3 OCR Documents Table
|
||||
|
||||
- **Component**: `OcrDocumentsTable`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 23. User Management (`/users`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 23.1 User Tab
|
||||
|
||||
- **Component**: `UserTab`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Internal Backend user management
|
||||
|
||||
#### 23.2 Role Tab
|
||||
|
||||
- **Component**: `RoleTab`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
#### 23.3 Permission Manager
|
||||
|
||||
- **Component**: `PermissionManager`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 24. Authentication Pages (`/login`, `/forgot-password`)
|
||||
|
||||
### Page Components
|
||||
|
||||
#### 24.1 Login Form
|
||||
|
||||
- **Component**: `LoginForm`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
- **Future API**: Internal Backend auth service
|
||||
|
||||
#### 24.2 Forgot Password Form
|
||||
|
||||
- **Component**: `ForgotPasswordForm`
|
||||
- **Data Source**: Mock Data (to be implemented)
|
||||
|
||||
---
|
||||
|
||||
## 25. Audit Logs (Embedded)
|
||||
|
||||
### Component
|
||||
|
||||
#### 25.1 Audit Log Viewer
|
||||
|
||||
- **Data Source**: Internal Backend
|
||||
- **API**: `GET /api/audit-logs?table={table}&recordId={id}&startDate={start}&endDate={end}&limit={limit}&offset={offset}`
|
||||
- **Count API**: `GET /api/audit-logs/count?table={table}`
|
||||
- **Tracks**: All database changes
|
||||
- **Fields**:
|
||||
- Table name
|
||||
- Record ID
|
||||
- Action (INSERT, UPDATE, DELETE)
|
||||
- Old values
|
||||
- New values
|
||||
- Timestamp
|
||||
- User (if applicable)
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Category | Internal Backend | External API (CPSP) | Mock/TBD |
|
||||
| ----------------------------- | ------------------ | ------------------- | -------- |
|
||||
| **Chicken Counting** | ✓ | | |
|
||||
| **Weight Monitoring** | | ✓ | |
|
||||
| **Weight Distribution** | | ✓ (with cache) | |
|
||||
| **FCR Calculation** | | ✓ (with cache) | |
|
||||
| **Feed Sack Counting** | ✓ (manual entries) | ✓ (camera counts) | |
|
||||
| **IoT Sensors (Environment)** | | ✓ | |
|
||||
| **IoT Devices/Scales** | | ✓ | |
|
||||
| **Cycle Management** | ✓ | | |
|
||||
| **Mortality Tracking** | ✓ | | |
|
||||
| **Kandang Management** | ✓ | | |
|
||||
| **Location/Cage Sites** | | ✓ | |
|
||||
| **Manual Feed Sack** | ✓ | | |
|
||||
| **Audit Logs** | ✓ | | |
|
||||
| **Column Config** | ✓ | | |
|
||||
| **Behavior Analysis** | | | ✓ |
|
||||
| **Vet Visits** | | | ✓ |
|
||||
| **Feed Scheduling** | | | ✓ |
|
||||
| **Feed Wastage** | Partial | Partial | ✓ |
|
||||
| **User Management** | | | ✓ |
|
||||
| **Authentication** | | | ✓ |
|
||||
| **Network Monitoring** | | | ✓ |
|
||||
| **Batch Processing** | | | ✓ |
|
||||
| **OCR Processing** | | | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
### Weight Distribution Cache
|
||||
|
||||
- **Type**: 1-hour cache
|
||||
- **Storage**: localStorage
|
||||
- **Key Pattern**: `weight-dist-1hour-{groupNumber}`
|
||||
- **Grouping**: 14-day periods aligned to cycle start
|
||||
- **Clear Function**: `apiClient.clearWeightDistributionCache()`
|
||||
|
||||
### FCR Cache
|
||||
|
||||
- **Type**: 24-hour cache
|
||||
- **Storage**: localStorage
|
||||
- **Key Pattern**: `fcr-cache-{dow}-{population}-{day}`
|
||||
- **Purpose**: Reduce API calls for standard reference data
|
||||
|
||||
---
|
||||
|
||||
## API Mode Configuration
|
||||
|
||||
The application supports two modes:
|
||||
|
||||
- **LIVE Mode**: Fetches from real APIs
|
||||
- **DEMO Mode**: Uses mock data
|
||||
|
||||
Mode is set in `AppContext` and passed to all `apiClient` functions.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5001/api # Internal backend base URL
|
||||
VITE_CHICKEN_COUNTING_API_KEY=<key> # API key for chicken counting submission
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
1. **External API (CPSP)** is hardcoded to `https://dashboard.cpsp.id` in `apiService.ts`
|
||||
2. **Internal Backend** uses `VITE_API_URL` environment variable with fallback to `localhost:5001`
|
||||
3. **Caching** is implemented for high-frequency endpoints (Weight Distribution, FCR)
|
||||
4. **Manual overrides** are stored in Internal Backend and merged with External API data (Feed Sack)
|
||||
5. **Mock data** is used for features under development (Behavior, Vet, Scheduling, etc.)
|
||||
6. **Real-time updates** use periodic polling, not WebSockets (yet)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-05-14
|
||||
@@ -0,0 +1,602 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
Complete guide for deploying Dashboard Solusi AI Peternakan Ayam using Docker containers.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Architecture](#architecture)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Deployment](#deployment)
|
||||
- [Management](#management)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
The application runs in 3 Docker containers:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Host Server │
|
||||
│ │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Frontend │ │ Backend │ │
|
||||
│ │ (Nginx) │◄────►│ (Node.js) │ │
|
||||
│ │ Port 80 │ │ Port 5001 │ │
|
||||
│ └────────────────┘ └──────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────┐ │
|
||||
│ │ │ Database │ │
|
||||
│ └──────────────►│ (PostgreSQL) │ │
|
||||
│ │ Port 5432 │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Container Details
|
||||
|
||||
| Container | Image | Port | Description |
|
||||
| ------------ | ------------------ | ---------- | -------------------------- |
|
||||
| **frontend** | nginx:alpine | 80 | React app served by Nginx |
|
||||
| **backend** | node:18-alpine | 5001 | Express API server |
|
||||
| **database** | postgres:16-alpine | 15432→5432 | PostgreSQL database server |
|
||||
|
||||
### Network Communication
|
||||
|
||||
- Frontend → Backend: Internal Docker network (`dashboard-network`)
|
||||
- Backend → Database: Internal Docker network (`dashboard-network`)
|
||||
- Client → Frontend: HTTP port 80 (or 5002 in dev)
|
||||
- Host → Database: TCP port 15432 (for DBeaver, psql, backups)
|
||||
|
||||
## 📦 Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
- **Docker**: v20.10+ ([Install](https://docs.docker.com/get-docker/))
|
||||
- **Docker Compose**: v2.0+ ([Install](https://docs.docker.com/compose/install/))
|
||||
|
||||
Check versions:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
### Server Requirements
|
||||
|
||||
- **OS**: Linux (Ubuntu 20.04+, Debian 11+, CentOS 8+)
|
||||
- **RAM**: Minimum 2GB, Recommended 4GB+
|
||||
- **Disk**: Minimum 10GB free space
|
||||
- **CPU**: 2+ cores recommended
|
||||
|
||||
### Ports
|
||||
|
||||
Ensure these ports are available:
|
||||
|
||||
- **80** or **5002**: Frontend (HTTP)
|
||||
- **5001**: Backend API
|
||||
- **15432**: PostgreSQL (for local access, DBeaver, backups)
|
||||
|
||||
Check port availability:
|
||||
|
||||
```bash
|
||||
sudo lsof -i :80
|
||||
sudo lsof -i :5001
|
||||
sudo lsof -i :15432
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd dashboard-solusi-ai-peternakan-ayam
|
||||
```
|
||||
|
||||
### 2. Configure Environment (Optional)
|
||||
|
||||
```bash
|
||||
# Copy and edit if you need custom settings
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 3. Build and Start Containers
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
- ✅ Build frontend and backend images
|
||||
- ✅ Create containers
|
||||
- ✅ Initialize database
|
||||
- ✅ Start all services
|
||||
|
||||
### 4. Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Test frontend
|
||||
curl http://localhost
|
||||
|
||||
# Test backend
|
||||
curl http://localhost/api/health
|
||||
```
|
||||
|
||||
### 5. Access Application
|
||||
|
||||
Open browser: **http://your-server-ip**
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Frontend (.env.example)
|
||||
|
||||
```env
|
||||
# API URL - Uses nginx proxy in Docker
|
||||
VITE_API_URL=/api
|
||||
```
|
||||
|
||||
#### Backend Environment Variables
|
||||
|
||||
These are set in docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=5001
|
||||
- DB_HOST=database
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${DB_USER:-dashboard_user}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-change_this_password}
|
||||
- DB_NAME=${DB_NAME:-dashboard_db}
|
||||
- DB_SSL=false
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
**1. Create environment file:**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
**2. Edit docker-compose.yml:**
|
||||
|
||||
Change ports:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
ports:
|
||||
- '8080:80' # Change from 80 to 8080
|
||||
```
|
||||
|
||||
Change database credentials:
|
||||
|
||||
```yaml
|
||||
# Create .env file in project root:
|
||||
DB_USER=my_user
|
||||
DB_PASSWORD=my_secure_password
|
||||
DB_NAME=my_database
|
||||
```
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
### Development Deployment
|
||||
|
||||
```bash
|
||||
# Build and start with logs
|
||||
docker-compose up --build
|
||||
|
||||
# Press Ctrl+C to stop
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
```bash
|
||||
# Build and start in detached mode
|
||||
docker-compose up -d --build
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop viewing logs (Ctrl+C, containers keep running)
|
||||
```
|
||||
|
||||
### Update Deployment
|
||||
|
||||
When code changes:
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Zero-Downtime Update
|
||||
|
||||
```bash
|
||||
# Build new images
|
||||
docker-compose build
|
||||
|
||||
# Restart services one by one
|
||||
docker-compose up -d --no-deps --build backend
|
||||
docker-compose up -d --no-deps --build frontend
|
||||
```
|
||||
|
||||
## 🔧 Management
|
||||
|
||||
### Container Management
|
||||
|
||||
```bash
|
||||
# Start all containers
|
||||
docker-compose start
|
||||
|
||||
# Stop all containers
|
||||
docker-compose stop
|
||||
|
||||
# Restart all containers
|
||||
docker-compose restart
|
||||
|
||||
# Stop and remove containers
|
||||
docker-compose down
|
||||
|
||||
# Stop and remove containers + volumes
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All containers
|
||||
docker-compose logs -f
|
||||
|
||||
# Specific container
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# Last 100 lines
|
||||
docker-compose logs --tail=100
|
||||
|
||||
# Since timestamp
|
||||
docker-compose logs --since 2025-12-22T10:00:00
|
||||
```
|
||||
|
||||
### Execute Commands in Container
|
||||
|
||||
```bash
|
||||
# Backend shell
|
||||
docker-compose exec backend sh
|
||||
|
||||
# Frontend shell
|
||||
docker-compose exec frontend sh
|
||||
|
||||
# Database shell (PostgreSQL)
|
||||
docker-compose exec database psql -U dashboard_user -d dashboard_db
|
||||
|
||||
# Run seed in backend
|
||||
docker-compose exec backend node database/seed-postgres.js
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
```bash
|
||||
# Backup database (recommended)
|
||||
./scripts/backup-postgres.sh
|
||||
|
||||
# Manual backup
|
||||
docker-compose exec database sh -c \
|
||||
'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
|
||||
| gzip > backup-$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Restore database
|
||||
./scripts/restore-postgres-local.sh --reset backups/dashboard_db-YYYYMMDD.sql.gz
|
||||
|
||||
# Query database
|
||||
docker-compose exec database psql -U dashboard_user -d dashboard_db -c "SELECT * FROM cycles;"
|
||||
|
||||
# Connect with psql from host
|
||||
psql -h localhost -p 15432 -U dashboard_user -d dashboard_db
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Check container health
|
||||
docker-compose ps
|
||||
|
||||
# Expected output:
|
||||
# NAME STATUS
|
||||
# dashboard-database Up (healthy)
|
||||
# dashboard-backend Up (healthy)
|
||||
# dashboard-frontend Up (healthy)
|
||||
|
||||
# Manual health checks
|
||||
curl http://localhost:5002/health # Backend
|
||||
curl http://localhost:5002/api/cycles # API test
|
||||
|
||||
# Database health
|
||||
docker-compose exec database pg_isready -U dashboard_user
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
**Problem:** Container exits immediately
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
|
||||
# Common causes:
|
||||
# - Port already in use
|
||||
# - Missing dependencies
|
||||
# - Configuration error
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Remove and rebuild
|
||||
docker-compose down
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
**Problem:** Port 80 or 5001 already in use
|
||||
|
||||
```bash
|
||||
# Find process using port
|
||||
sudo lsof -i :80
|
||||
sudo lsof -i :5001
|
||||
|
||||
# Kill process
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# Or change port in docker-compose.yml
|
||||
```
|
||||
|
||||
### Database Not Persisting
|
||||
|
||||
**Problem:** Data lost after restart
|
||||
|
||||
```bash
|
||||
# Check volume
|
||||
docker volume ls | grep db-data
|
||||
docker volume inspect dashboard-solusi-ai-peternakan-ayam_db-data
|
||||
|
||||
# Check database is accessible
|
||||
docker-compose exec database psql -U dashboard_user -d dashboard_db -c "SELECT COUNT(*) FROM cycles;"
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Make sure you're not using -v flag when stopping
|
||||
docker-compose down # Preserves volumes
|
||||
# NOT: docker-compose down -v # This deletes volumes!
|
||||
|
||||
# Restart containers
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Frontend Can't Connect to Backend
|
||||
|
||||
**Problem:** API requests fail with CORS or connection errors
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
# Test backend directly
|
||||
curl http://localhost:5001/health
|
||||
|
||||
# Check nginx proxy config
|
||||
docker-compose exec frontend cat /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Check network
|
||||
docker network ls
|
||||
docker network inspect peternakan-network
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Verify both containers are on same network
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Out of Disk Space
|
||||
|
||||
**Problem:** Build fails with "no space left on device"
|
||||
|
||||
```bash
|
||||
# Check disk usage
|
||||
df -h
|
||||
|
||||
# Clean Docker
|
||||
docker system prune -a
|
||||
docker volume prune
|
||||
|
||||
# Remove unused images
|
||||
docker image prune -a
|
||||
```
|
||||
|
||||
### Database Connection Errors
|
||||
|
||||
**Problem:** Backend can't connect to database
|
||||
|
||||
```bash
|
||||
# Check database container is running and healthy
|
||||
docker-compose ps database
|
||||
|
||||
# Check logs
|
||||
docker-compose logs database
|
||||
docker-compose logs backend
|
||||
|
||||
# Verify connection from backend
|
||||
docker-compose exec backend sh -c 'psql -h database -U $DB_USER -d $DB_NAME -c "SELECT 1"'
|
||||
|
||||
# Restart services
|
||||
docker-compose restart database backend
|
||||
```
|
||||
|
||||
### Container Memory Issues
|
||||
|
||||
**Problem:** Container crashes with OOM
|
||||
|
||||
```bash
|
||||
# Check memory usage
|
||||
docker stats
|
||||
|
||||
# Add memory limits in docker-compose.yml:
|
||||
services:
|
||||
backend:
|
||||
mem_limit: 512m
|
||||
mem_reservation: 256m
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Resource Usage
|
||||
|
||||
```bash
|
||||
# Real-time stats
|
||||
docker stats
|
||||
|
||||
# Container processes
|
||||
docker-compose top
|
||||
```
|
||||
|
||||
### Logs Analysis
|
||||
|
||||
```bash
|
||||
# Search logs
|
||||
docker-compose logs | grep ERROR
|
||||
docker-compose logs | grep "Failed to"
|
||||
|
||||
# Export logs
|
||||
docker-compose logs > application.log
|
||||
```
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
### Production Recommendations
|
||||
|
||||
1. **Use HTTPS:**
|
||||
- Add reverse proxy (Nginx/Apache) with SSL
|
||||
- Get certificate from Let's Encrypt
|
||||
|
||||
2. **Firewall:**
|
||||
|
||||
```bash
|
||||
# Allow only necessary ports
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
3. **Update regularly:**
|
||||
|
||||
```bash
|
||||
# Update base images
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Secure database:**
|
||||
|
||||
```bash
|
||||
# Use strong passwords in production .env
|
||||
DB_PASSWORD=your_very_strong_password_here
|
||||
|
||||
# Enable SSL for production
|
||||
DB_SSL=true
|
||||
|
||||
# Block external database access (only allow from Docker network)
|
||||
# Edit docker-compose.yml: remove or restrict ports section for database
|
||||
```
|
||||
|
||||
## 🔄 Backup & Restore
|
||||
|
||||
### Backup
|
||||
|
||||
Use the provided script:
|
||||
|
||||
```bash
|
||||
./scripts/backup-postgres.sh
|
||||
```
|
||||
|
||||
This creates a compressed SQL dump in `./backups/dashboard_db-YYYYMMDD-HHMMSS.sql.gz`
|
||||
|
||||
**Manual backup:**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Manual backup example
|
||||
|
||||
BACKUP_DIR="./backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
docker-compose exec -T database sh -c \
|
||||
'PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl' \
|
||||
| gzip -c > "$BACKUP_DIR/dashboard_db-$DATE.sql.gz"
|
||||
|
||||
echo "Backup completed: $BACKUP_DIR/dashboard_db-$DATE.sql.gz"
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
Use the provided script:
|
||||
|
||||
```bash
|
||||
./scripts/restore-postgres-local.sh --reset backups/dashboard_db-YYYYMMDD-HHMMSS.sql.gz
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Stop and remove local Docker volumes
|
||||
2. Start fresh database
|
||||
3. Restore from backup file
|
||||
|
||||
**Note:** The `--reset` flag is required to prevent mixing old and new data.
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Docker Documentation](https://docs.docker.com/)
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Nginx Documentation](https://nginx.org/en/docs/)
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
For issues specific to Docker deployment:
|
||||
|
||||
1. Check logs: `docker-compose logs -f`
|
||||
2. Verify configuration: `docker-compose config`
|
||||
3. Check GitHub Issues
|
||||
4. Contact support@cpsp.id
|
||||
|
||||
---
|
||||
|
||||
**Docker deployment guide by PT Cipta Pola Solusi Prima - 2025**
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Frontend Dockerfile - Multi-stage build
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Set API URL for Docker deployment (nginx will proxy to backend)
|
||||
ENV VITE_API_URL=/api
|
||||
|
||||
# Set Chicken Counting API key
|
||||
ARG CHICKEN_COUNTING_API_KEY
|
||||
ENV VITE_CHICKEN_COUNTING_API_KEY=${CHICKEN_COUNTING_API_KEY}
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,84 @@
|
||||
# Product Requirements Document: Solusi AI Peternakan Ayam
|
||||
|
||||
**Versi:** 1.0
|
||||
**Tanggal:** 4 Agustus 2025
|
||||
**Pemilik Produk:** Divisi Digitalisasi Pertanian, Charoen Pokphand
|
||||
|
||||
---
|
||||
|
||||
## 1. Pendahuluan
|
||||
|
||||
### 1.1 Visi Produk
|
||||
Menjadi platform analitik prediktif terdepan di Asia Tenggara untuk industri peternakan ayam, memberdayakan peternak dengan data untuk meningkatkan efisiensi, kesejahteraan hewan, dan profitabilitas secara berkelanjutan.
|
||||
|
||||
### 1.2 Misi Produk
|
||||
Membangun solusi AI terintegrasi yang menyediakan wawasan real-time, dapat ditindaklanjuti, dan mudah diakses dari data operasional kandang, mulai dari pemantauan visual hingga manajemen sumber daya.
|
||||
|
||||
### 1.3 Audiens Target
|
||||
- **Manajer Peternakan:** Membutuhkan ringkasan performa, analitik tren, dan notifikasi kritis untuk pengambilan keputusan strategis.
|
||||
- **Operator Kandang:** Membutuhkan panduan tugas harian yang jelas, notifikasi real-time, dan alat bantu untuk penanganan masalah di lapangan.
|
||||
- **Dokter Hewan:** Membutuhkan data kesehatan agregat, deteksi dini penyakit melalui perilaku, dan riwayat kesehatan kandang.
|
||||
- **Manajemen Eksekutif:** Membutuhkan dashboard tingkat tinggi mengenai KPI utama (FCR, profitabilitas, populasi) di seluruh aset peternakan.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sasaran & Tujuan
|
||||
|
||||
### 2.1 Sasaran Bisnis
|
||||
- Mengurangi FCR (Feed Conversion Ratio) sebesar 5% dalam 1 tahun pertama.
|
||||
- Menurunkan tingkat mortalitas sebesar 10% melalui deteksi dini.
|
||||
- Meningkatkan efisiensi tenaga kerja operasional sebesar 15%.
|
||||
- Mengurangi pemborosan pakan sebesar 20%.
|
||||
|
||||
### 2.2 Sasaran Pengguna
|
||||
- **Manajer:** Mengurangi waktu pembuatan laporan manual dari 4 jam/minggu menjadi <15 menit/minggu.
|
||||
- **Operator:** Menyelesaikan 95% tugas kritis yang dihasilkan AI dalam waktu 1 jam setelah notifikasi.
|
||||
- **Dokter Hewan:** Mendapatkan akses terpusat ke data kesehatan dan perilaku untuk 100% kandang yang terhubung.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fitur Utama
|
||||
|
||||
### 3.1 Dashboard & Analitik Terpusat
|
||||
- Tampilan ringkas KPI utama dengan tren historis.
|
||||
- Peta panas (heatmap) untuk kepadatan populasi dan hotspot masalah.
|
||||
- Laporan AI generatif untuk ringkasan harian.
|
||||
|
||||
### 3.2 Analisis Visual Berbasis AI
|
||||
- **Penghitungan Ayam:** Penghitungan populasi secara otomatis dan akurat.
|
||||
- **Deteksi Mortalitas:** Notifikasi instan saat ada ayam mati.
|
||||
- **Analisis Perilaku:** Deteksi stres, agresi, dan penyakit melalui pola perilaku.
|
||||
- **Pemantauan Pakan:** Estimasi konsumsi, deteksi pemborosan, dan penghitungan karung.
|
||||
|
||||
### 3.3 Pemantauan Lingkungan & IoT
|
||||
- Integrasi dengan sensor suhu, kelembapan, amonia, dll.
|
||||
- Visualisasi data sensor historis.
|
||||
- Notifikasi jika parameter lingkungan keluar dari ambang batas aman.
|
||||
|
||||
### 3.4 Aplikasi Mobile Pendamping
|
||||
- Tampilan yang disesuaikan untuk setiap peran (Manajer, Operator, Dokter Hewan).
|
||||
- Notifikasi push untuk peringatan kritis.
|
||||
- Alur kerja untuk menangani tugas yang dihasilkan oleh AI (misalnya, menangani mortalitas).
|
||||
|
||||
---
|
||||
|
||||
## 4. User Stories (Contoh)
|
||||
|
||||
- **Sebagai Manajer Peternakan,** saya ingin melihat perbandingan FCR dan ADG (Average Daily Gain) antar kandang agar saya bisa mengidentifikasi kandang yang berkinerja buruk dan baik.
|
||||
- **Sebagai Operator Kandang,** saya ingin menerima notifikasi di ponsel saya ketika ada ayam yang terdeteksi mati, lengkap dengan lokasinya, agar saya dapat segera menanganinya.
|
||||
- **Sebagai Dokter Hewan,** saya ingin melihat tren tingkat stres dan aktivitas di semua kandang untuk mendeteksi potensi wabah penyakit lebih awal.
|
||||
|
||||
---
|
||||
|
||||
## 5. Metrik Keberhasilan
|
||||
- Tingkat adopsi pengguna (Daily Active Users / Monthly Active Users).
|
||||
- Jumlah notifikasi kritis yang ditindaklanjuti dalam SLA yang ditentukan.
|
||||
- Korelasi antara penggunaan fitur AI dengan peningkatan KPI bisnis (FCR, mortalitas).
|
||||
- Skor kepuasan pengguna (diukur melalui survei internal).
|
||||
|
||||
---
|
||||
|
||||
## 6. Di Luar Cakupan (V1)
|
||||
- Kontrol aktuator secara otomatis (misalnya, menyalakan kipas).
|
||||
- Integrasi dengan sistem keuangan untuk perhitungan profitabilitas real-time.
|
||||
- Analisis kualitas kotoran (litter quality).
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
# 🚀 Quick Start Guide
|
||||
|
||||
Panduan cepat untuk menjalankan Dashboard Solusi AI Peternakan Ayam.
|
||||
|
||||
## ⚡ TL;DR - Super Quick Start
|
||||
|
||||
### Opsi 1: Docker (Recommended for Production) 🐳
|
||||
|
||||
```bash
|
||||
# 1. Install Docker & Docker Compose
|
||||
# 2. Clone repository
|
||||
git clone <repo-url>
|
||||
cd dashboard-solusi-ai-peternakan-ayam
|
||||
|
||||
# 3. Deploy
|
||||
docker-compose up -d --build
|
||||
|
||||
# 4. Access
|
||||
# http://localhost
|
||||
```
|
||||
|
||||
### Opsi 2: Manual (For Development) 💻
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
cd backend && npm install && cd ..
|
||||
|
||||
# 2. Terminal 1 - Start Backend
|
||||
cd backend
|
||||
npm run dev
|
||||
|
||||
# 3. Terminal 2 - Start Frontend
|
||||
npm run dev
|
||||
|
||||
# 4. Open browser
|
||||
# Frontend: http://localhost:3001
|
||||
# Backend API: http://localhost:5001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment (Recommended)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- ✅ Docker v20.10+ ([Download](https://docs.docker.com/get-docker/))
|
||||
- ✅ Docker Compose v2.0+ ([Download](https://docs.docker.com/compose/install/))
|
||||
|
||||
### Quick Deploy
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repo-url>
|
||||
cd dashboard-solusi-ai-peternakan-ayam
|
||||
|
||||
# Create data directory
|
||||
mkdir -p data
|
||||
|
||||
# Deploy
|
||||
docker-compose up -d --build
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
**Access:** http://localhost or http://your-server-ip
|
||||
|
||||
**📚 Complete Docker Guide:** [DOCKER.md](DOCKER.md)
|
||||
|
||||
---
|
||||
|
||||
## 💻 Manual Installation
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
Pastikan sudah terinstall:
|
||||
|
||||
- ✅ Node.js v18+ ([Download](https://nodejs.org/))
|
||||
- ✅ npm v9+
|
||||
- ✅ Port 3001 dan 5001 tersedia
|
||||
|
||||
Cek versi:
|
||||
|
||||
```bash
|
||||
node --version # Should be v18.0.0 or higher
|
||||
npm --version # Should be v9.0.0 or higher
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Step 1: Install Dependencies
|
||||
|
||||
### Frontend Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Backend Dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
- Jika error `EACCES`: Gunakan `sudo npm install` atau fix npm permissions
|
||||
- Jika error `node-gyp`: Install build tools untuk OS Anda
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Step 2: Start Backend Server
|
||||
|
||||
**Buka Terminal 1:**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**✅ Output yang benar:**
|
||||
|
||||
```
|
||||
========================================
|
||||
🚀 Server running on http://localhost:5001
|
||||
========================================
|
||||
|
||||
API Endpoints:
|
||||
GET /health
|
||||
GET /api/cycles
|
||||
GET /api/cycles/active
|
||||
...
|
||||
|
||||
✓ Database initialized successfully
|
||||
```
|
||||
|
||||
**❌ Jika gagal:**
|
||||
|
||||
**Problem:** Port 5001 sudah terpakai
|
||||
|
||||
```bash
|
||||
# Check what's using port 5001
|
||||
lsof -i :5001
|
||||
|
||||
# Solution: Change port in backend/.env
|
||||
PORT=5002
|
||||
```
|
||||
|
||||
**Problem:** Database error
|
||||
|
||||
```bash
|
||||
# Make sure PostgreSQL is running
|
||||
# For local development, check backend/.env for DB credentials
|
||||
cd backend
|
||||
node database/seed-postgres.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Step 3: Start Frontend Server
|
||||
|
||||
**Buka Terminal 2 (baru):**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**✅ Output yang benar:**
|
||||
|
||||
```
|
||||
VITE v6.4.1 ready in 155 ms
|
||||
|
||||
➜ Local: http://localhost:3001/
|
||||
➜ Network: http://127.0.2.2:3001/
|
||||
```
|
||||
|
||||
**Note:** Jika port 3001 terpakai, Vite akan otomatis menggunakan port lain (misalnya 3002).
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Step 4: Open in Browser
|
||||
|
||||
Buka browser dan akses:
|
||||
|
||||
**Frontend Dashboard:**
|
||||
|
||||
```
|
||||
http://localhost:3001
|
||||
```
|
||||
|
||||
**Backend API (optional):**
|
||||
|
||||
```
|
||||
http://localhost:5001/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verify Installation
|
||||
|
||||
### Test 1: Backend Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:5001/health
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
|
||||
```json
|
||||
{ "status": "ok", "timestamp": "2025-12-22T03:02:20.793Z" }
|
||||
```
|
||||
|
||||
### Test 2: Get Cycles Data
|
||||
|
||||
```bash
|
||||
curl http://localhost:5001/api/cycles
|
||||
```
|
||||
|
||||
**Expected response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "CYCLE-JBW-2025-12-10",
|
||||
"totalDays": 42,
|
||||
"currentDay": 7,
|
||||
"startDate": "2025-12-10",
|
||||
"endDate": "2026-01-20",
|
||||
"docInCount": 20000,
|
||||
"status": "Active",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Test 3: Frontend Loads
|
||||
|
||||
1. Open http://localhost:3001
|
||||
2. You should see the dashboard with:
|
||||
- ✅ KPI cards (Population, Mortality, etc.)
|
||||
- ✅ Linimasa Siklus sidebar menu
|
||||
- ✅ No console errors (press F12)
|
||||
|
||||
### Test 4: Database Integration
|
||||
|
||||
1. Navigate to "Linimasa Siklus Produksi"
|
||||
2. Click "Edit" on active cycle
|
||||
3. Change DOC In count: 20000 → 21000
|
||||
4. Click "Simpan"
|
||||
5. Refresh page (F5)
|
||||
6. **✅ Data should persist** - DOC count still shows 21000
|
||||
|
||||
---
|
||||
|
||||
## 📚 Next Steps
|
||||
|
||||
### Explore Features
|
||||
|
||||
1. **Dashboard Utama** - View KPI and real-time monitoring
|
||||
2. **Linimasa Siklus** - Manage production cycles
|
||||
3. **Hitung Ayam > Manajemen Data** - Edit mortality data
|
||||
4. **Monitoring Berat** - View weight distribution
|
||||
5. **Standar Performa** - Compare with Cobb standards
|
||||
|
||||
### Test Database Persistence
|
||||
|
||||
**Test Mortality Data:**
|
||||
|
||||
1. Go to "Hitung Ayam" > "Manajemen Data"
|
||||
2. Click "Edit" on any day
|
||||
3. Change mortality value
|
||||
4. Click save (✓)
|
||||
5. Refresh page - data should persist
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Common Issues
|
||||
|
||||
### Issue: "Cannot connect to backend"
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Frontend shows no data
|
||||
- Console shows: `Failed to load cycles from database`
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Check backend is running: `curl http://localhost:5001/health`
|
||||
2. Check CORS settings in `backend/server.js`
|
||||
3. Check frontend API URL in `services/apiService.ts`:
|
||||
```typescript
|
||||
const DB_API_BASE_URL = 'http://localhost:5001/api';
|
||||
```
|
||||
|
||||
### Issue: "Port already in use"
|
||||
|
||||
**Backend (5001):**
|
||||
|
||||
```bash
|
||||
# Find process using port 5001
|
||||
lsof -i :5001
|
||||
|
||||
# Kill the process (replace PID)
|
||||
kill -9 <PID>
|
||||
|
||||
# Or change port in backend/.env
|
||||
```
|
||||
|
||||
**Frontend (3001):**
|
||||
|
||||
- Vite will auto-select another port
|
||||
- Check terminal output for actual port
|
||||
|
||||
### Issue: "Database not found" or "Connection refused"
|
||||
|
||||
```bash
|
||||
# Make sure PostgreSQL is running locally
|
||||
# Check connection settings in backend/.env:
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_USER=dashboard_user
|
||||
# DB_PASSWORD=your_password
|
||||
# DB_NAME=dashboard_db
|
||||
|
||||
cd backend
|
||||
node database/seed-postgres.js
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Issue: "Module not found"
|
||||
|
||||
```bash
|
||||
# Reinstall dependencies
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Backend
|
||||
cd backend
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛑 Stopping Servers
|
||||
|
||||
**Stop Backend:**
|
||||
|
||||
```bash
|
||||
# In backend terminal, press:
|
||||
Ctrl + C
|
||||
```
|
||||
|
||||
**Stop Frontend:**
|
||||
|
||||
```bash
|
||||
# In frontend terminal, press:
|
||||
Ctrl + C
|
||||
```
|
||||
|
||||
**Kill All Node Processes (if stuck):**
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
killall node
|
||||
|
||||
# Windows
|
||||
taskkill /F /IM node.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Full Documentation
|
||||
|
||||
For detailed documentation, see:
|
||||
|
||||
- **[README.md](README.md)** - Complete project documentation
|
||||
- **[backend/README.md](backend/README.md)** - Backend API documentation
|
||||
- **API Endpoints** - http://localhost:5001 (when server is running)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Keep both terminals open** - Don't close backend and frontend terminals
|
||||
2. **Check logs** - Backend logs show all API requests and SQL queries
|
||||
3. **Use browser DevTools** - F12 to see network requests and errors
|
||||
4. **Database is PostgreSQL** - Connection details in `backend/.env`
|
||||
5. **Auto-reload** - Both frontend and backend support hot reload
|
||||
6. **Local database guide** - See [docs/LOCAL_DATABASE_GUIDE.md](docs/LOCAL_DATABASE_GUIDE.md) for syncing with production
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features Quick Reference
|
||||
|
||||
| Feature | Location | Description |
|
||||
| -------------- | ---------------------------- | ------------------------- |
|
||||
| View Cycles | Linimasa Siklus | See all production cycles |
|
||||
| Edit Cycle | Linimasa > Edit | Modify dates, DOC count |
|
||||
| Mortality Data | Hitung Ayam > Manajemen Data | CRUD mortality records |
|
||||
| Weight Stats | Monitoring Berat | View weight distribution |
|
||||
| Cobb Standards | Standar Performa | Compare with standards |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Checklist
|
||||
|
||||
- [ ] Backend running on port 5001
|
||||
- [ ] Frontend running on port 3001
|
||||
- [ ] `/health` endpoint returns `{"status":"ok"}`
|
||||
- [ ] `/api/cycles` returns 4 cycles
|
||||
- [ ] Dashboard loads in browser
|
||||
- [ ] Can edit cycle data and it persists
|
||||
- [ ] Can edit mortality data and it persists
|
||||
- [ ] No errors in browser console
|
||||
- [ ] No errors in backend logs
|
||||
|
||||
---
|
||||
|
||||
**Ready to go! 🚀**
|
||||
|
||||
If you encounter any issues not covered here, check the full [README.md](README.md) or [backend/README.md](backend/README.md).
|
||||
@@ -0,0 +1,319 @@
|
||||
# Quick Reference Guide
|
||||
|
||||
**For:** Daily development with quality improvements
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Common Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
npm run lint # Check code quality
|
||||
npm run lint:fix # Auto-fix linting issues
|
||||
npm run format # Format all files
|
||||
npm run type-check # Check TypeScript errors
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
npm test # Run tests (watch mode)
|
||||
npm run test:run # Run tests once
|
||||
npm run test:ui # Visual test UI
|
||||
npm run test:coverage # Coverage report
|
||||
|
||||
# Backend tests
|
||||
cd backend
|
||||
npm test
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
### Git Hooks
|
||||
|
||||
```bash
|
||||
git commit # Automatically lints changed files
|
||||
git commit --no-verify # Skip hooks (emergency only)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Quick Fixes
|
||||
|
||||
### "Pre-commit hook failed"
|
||||
|
||||
```bash
|
||||
npm run lint:fix # Auto-fix issues
|
||||
git add .
|
||||
git commit -m "your message"
|
||||
```
|
||||
|
||||
### "TypeScript errors"
|
||||
|
||||
```bash
|
||||
npm run type-check # See all errors
|
||||
# Fix the errors in your editor
|
||||
```
|
||||
|
||||
### "Tests failing"
|
||||
|
||||
```bash
|
||||
npm test # See which tests fail
|
||||
# Fix the code or update tests
|
||||
```
|
||||
|
||||
### "ESLint errors"
|
||||
|
||||
```bash
|
||||
npm run lint # See errors
|
||||
npm run lint:fix # Auto-fix what's possible
|
||||
# Manually fix remaining issues
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pre-Push Checklist
|
||||
|
||||
Before pushing to GitHub:
|
||||
|
||||
- [ ] `npm run lint` ✅
|
||||
- [ ] `npm run type-check` ✅
|
||||
- [ ] `npm run build` ✅
|
||||
- [ ] `npm test` ✅
|
||||
|
||||
**One-liner:**
|
||||
|
||||
```bash
|
||||
npm run lint && npm run type-check && npm run build && npm test -- --run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 When Things Go Wrong
|
||||
|
||||
### CI Failed on GitHub
|
||||
|
||||
1. Go to repository → Actions tab
|
||||
2. Click the failed workflow
|
||||
3. Read the error logs
|
||||
4. Fix locally, test, then push again
|
||||
|
||||
### Can't Commit
|
||||
|
||||
- Pre-commit hook is blocking
|
||||
- Run `npm run lint:fix`
|
||||
- Or bypass (emergency): `git commit --no-verify`
|
||||
|
||||
### TypeScript Strict Mode Complaints
|
||||
|
||||
- Fix the type errors (recommended)
|
||||
- Or add type assertions (not recommended)
|
||||
|
||||
```typescript
|
||||
const data = apiResponse as MyType; // Last resort
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Important Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------------- | --------------------------------------- |
|
||||
| `.eslintrc.json` | ESLint config (frontend) |
|
||||
| `backend/.eslintrc.json` | ESLint config (backend) |
|
||||
| `.prettierrc` | Code formatting rules |
|
||||
| `tsconfig.json` | TypeScript config (strict mode enabled) |
|
||||
| `.husky/pre-commit` | Git pre-commit hook |
|
||||
| `.github/workflows/ci.yml` | CI/CD pipeline |
|
||||
| `components/common/ErrorBoundary.tsx` | Error boundary component |
|
||||
| `backend/utils/errors.js` | Custom error classes |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Writing Tests
|
||||
|
||||
### Backend Model Test
|
||||
|
||||
```javascript
|
||||
// backend/models/__tests__/MyModel.test.js
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import MyModel from '../MyModel.js';
|
||||
import { query } from '../../database/db.js';
|
||||
|
||||
describe('MyModel', () => {
|
||||
beforeEach(async () => {
|
||||
await query('DELETE FROM my_table WHERE id LIKE $1', ['TEST-%']);
|
||||
});
|
||||
|
||||
it('should do something', async () => {
|
||||
const result = await MyModel.doSomething();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await query('DELETE FROM my_table WHERE id LIKE $1', ['TEST-%']);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Frontend Component Test
|
||||
|
||||
```typescript
|
||||
// components/__tests__/MyComponent.test.tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MyComponent from '../MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<MyComponent title="Hello" />);
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Using Error Classes
|
||||
|
||||
### Backend Route with Error Handling
|
||||
|
||||
```javascript
|
||||
const { ValidationError, NotFoundError } = require('./utils/errors');
|
||||
|
||||
router.get('/api/cycles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new ValidationError('Cycle ID is required');
|
||||
}
|
||||
|
||||
const cycle = await Cycle.getById(id);
|
||||
|
||||
if (!cycle) {
|
||||
throw new NotFoundError('Cycle');
|
||||
}
|
||||
|
||||
res.json({ success: true, data: cycle });
|
||||
} catch (error) {
|
||||
next(error); // Error handler sanitizes for client
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Error Boundary Usage
|
||||
|
||||
### Wrap Risky Components
|
||||
|
||||
```tsx
|
||||
import ErrorBoundary from '@/components/common/ErrorBoundary';
|
||||
|
||||
function MyPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<RiskyComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom fallback
|
||||
<ErrorBoundary fallback={<div>Oops, data loading failed</div>}>
|
||||
<DataTable />
|
||||
</ErrorBoundary>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Viewing Test Coverage
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
# Opens HTML report in coverage/index.html
|
||||
```
|
||||
|
||||
**Coverage Goals:**
|
||||
|
||||
- Critical models: >80%
|
||||
- Utility functions: >80%
|
||||
- Components: >50%
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Code Style Tips
|
||||
|
||||
### Auto-format on save (VS Code)
|
||||
|
||||
Add to `.vscode/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable ESLint for one line
|
||||
|
||||
```typescript
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: any = legacyApi();
|
||||
```
|
||||
|
||||
### Disable TypeScript for one line
|
||||
|
||||
```typescript
|
||||
// @ts-ignore
|
||||
const result = oldCode();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Emergency Procedures
|
||||
|
||||
### Disable Pre-commit Hooks Temporarily
|
||||
|
||||
```bash
|
||||
git commit --no-verify -m "emergency fix"
|
||||
```
|
||||
|
||||
### Skip TypeScript Errors Temporarily
|
||||
|
||||
Comment out in `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// "strict": true, // Commented temporarily
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Remember to re-enable!**
|
||||
|
||||
---
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
**ESLint issues:** Check `.eslintrc.json` rules
|
||||
**TypeScript errors:** Check `tsconfig.json` compiler options
|
||||
**Test failures:** Check test files in `__tests__/` directories
|
||||
**CI failures:** Check GitHub Actions logs
|
||||
|
||||
**Full documentation:** See `QUALITY_IMPROVEMENTS.md`
|
||||
@@ -0,0 +1,665 @@
|
||||
# Dashboard Solusi AI Peternakan Ayam
|
||||
|
||||
Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi IoT, AI, dan database PostgreSQL untuk penyimpanan data siklus produksi.
|
||||
|
||||
## 📋 Daftar Isi
|
||||
|
||||
- [Fitur Utama](#fitur-utama)
|
||||
- [Teknologi](#teknologi)
|
||||
- [Deployment](#deployment)
|
||||
- [Persyaratan Sistem](#persyaratan-sistem)
|
||||
- [Instalasi](#instalasi)
|
||||
- [Cara Menjalankan](#cara-menjalankan)
|
||||
- [Struktur Proyek](#struktur-proyek)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Database Schema](#database-schema)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## 🚀 Fitur Utama
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Dashboard Utama**: Monitoring real-time KPI, alert, dan kondisi kandang
|
||||
- **Linimasa Siklus**: Manajemen siklus produksi dengan timeline visual
|
||||
- **Hitung Ayam**:
|
||||
- Dashboard perhitungan populasi
|
||||
- **Manajemen Data**: CRUD data mortalitas dengan penyimpanan database
|
||||
- **Monitoring Berat**: Tracking berat ayam dengan distribusi dan statistik
|
||||
- **Standar Performa**: Perbandingan dengan standar Cobb
|
||||
- **Penjadwalan Pakan**: Rekomendasi jadwal pemberian pakan
|
||||
- **Feed Wastage**: Monitoring pemborosan pakan
|
||||
- **Behavior Analysis**: Analisis perilaku ayam dengan AI
|
||||
- **Network Monitoring**: Status koneksi IoT devices
|
||||
- **ERP Integration**: Sinkronisasi dengan sistem ERP
|
||||
|
||||
### Backend (PostgreSQL Database)
|
||||
|
||||
- **Database**: PostgreSQL untuk penyimpanan data siklus dan mortalitas
|
||||
- **RESTful API**: Endpoint untuk CRUD operations
|
||||
- **Auto-seeding**: Database otomatis terisi data awal saat pertama kali dijalankan
|
||||
- **Persistent Storage**: Semua perubahan data tersimpan permanen
|
||||
|
||||
## 🛠 Teknologi
|
||||
|
||||
### Frontend
|
||||
|
||||
- **React 18** dengan TypeScript
|
||||
- **Vite** - Build tool & dev server
|
||||
- **Recharts** - Data visualization
|
||||
- **Tailwind CSS** - Styling
|
||||
- **Context API** - State management
|
||||
|
||||
### Backend
|
||||
|
||||
- **Node.js** dengan Express
|
||||
- **PostgreSQL** (pg) - Database
|
||||
- **CORS** - Cross-origin support
|
||||
|
||||
### Deployment
|
||||
|
||||
- **Docker** - Containerization
|
||||
- **Docker Compose** - Multi-container orchestration (3 containers: frontend, backend, database)
|
||||
- **Nginx** - Reverse proxy & static file serving
|
||||
- **PostgreSQL** - Database container
|
||||
|
||||
## 🐳 Deployment
|
||||
|
||||
### Docker Deployment (Recommended for Production)
|
||||
|
||||
Deploy dengan 3 container (frontend + backend + PostgreSQL database) menggunakan Docker Compose:
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
docker-compose up -d --build
|
||||
|
||||
# Access application
|
||||
http://your-server-ip
|
||||
```
|
||||
|
||||
**Keuntungan Docker:**
|
||||
|
||||
- ✅ Easy deployment dan scaling
|
||||
- ✅ Isolated environment
|
||||
- ✅ Consistent across different servers
|
||||
- ✅ Automatic restarts
|
||||
- ✅ Built-in health checks
|
||||
|
||||
**📚 Complete Docker Guide:** [DOCKER.md](DOCKER.md)
|
||||
|
||||
**Local Postgres / sync from server / DBeaver:** [docs/LOCAL_DATABASE_GUIDE.md](docs/LOCAL_DATABASE_GUIDE.md)
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
Untuk development atau testing lokal, lihat [Instalasi](#instalasi) dan [Cara Menjalankan](#cara-menjalankan) di bawah.
|
||||
|
||||
## 📦 Persyaratan Sistem
|
||||
|
||||
### Untuk Docker Deployment
|
||||
|
||||
- **Docker**: v20.10+
|
||||
- **Docker Compose**: v2.0+
|
||||
- **RAM**: Minimum 2GB
|
||||
- **Disk**: 10GB free space
|
||||
- **Port**: 80 (frontend)
|
||||
|
||||
### Untuk Manual Installation
|
||||
|
||||
- **Node.js**: v18.0.0 atau lebih tinggi
|
||||
- **npm**: v9.0.0 atau lebih tinggi
|
||||
- **Port yang tersedia**:
|
||||
- Frontend: 3001 (atau port lain jika 3001 terpakai)
|
||||
- Backend: 5001
|
||||
|
||||
## 💿 Instalasi
|
||||
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd dashboard-solusi-ai-peternakan-ayam
|
||||
```
|
||||
|
||||
### 2. Install Dependencies Frontend
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Install Dependencies Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
## ▶️ Cara Menjalankan
|
||||
|
||||
### Opsi 1: Menjalankan Secara Manual (Recommended untuk Development)
|
||||
|
||||
#### Terminal 1 - Backend Server
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Backend akan berjalan di: **http://localhost:5001**
|
||||
|
||||
Output yang diharapkan:
|
||||
|
||||
```
|
||||
========================================
|
||||
🚀 Server running on http://localhost:5001
|
||||
========================================
|
||||
|
||||
API Endpoints:
|
||||
GET /health
|
||||
GET /api/cycles
|
||||
GET /api/cycles/active
|
||||
...
|
||||
```
|
||||
|
||||
#### Terminal 2 - Frontend Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend akan berjalan di: **http://localhost:3001** (atau port lain jika terpakai)
|
||||
|
||||
Output yang diharapkan:
|
||||
|
||||
```
|
||||
VITE v6.4.1 ready in 155 ms
|
||||
|
||||
➜ Local: http://localhost:3001/
|
||||
```
|
||||
|
||||
### Opsi 2: Menjalankan dengan satu perintah
|
||||
|
||||
Buka dua terminal secara terpisah:
|
||||
|
||||
**Terminal 1:**
|
||||
|
||||
```bash
|
||||
cd backend && npm run dev
|
||||
```
|
||||
|
||||
**Terminal 2:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Atau gunakan terminal multiplexer seperti `tmux` atau `screen`.
|
||||
|
||||
## 📁 Struktur Proyek
|
||||
|
||||
```
|
||||
dashboard-solusi-ai-peternakan-ayam/
|
||||
├── backend/ # Backend Node.js + PostgreSQL
|
||||
│ ├── database/
|
||||
│ │ ├── db.js # PostgreSQL connection & pool setup
|
||||
│ │ ├── schema.sql # Database schema
|
||||
│ │ ├── seed-postgres.js # Data seeding script
|
||||
│ │ └── migrations/ # Database migrations
|
||||
│ ├── models/
|
||||
│ │ ├── Cycle.js # Cycle data model
|
||||
│ │ └── Mortality.js # Mortality data model
|
||||
│ ├── routes/
|
||||
│ │ ├── cycles.js # Cycle endpoints
|
||||
│ │ └── mortality.js # Mortality endpoints
|
||||
│ ├── server.js # Express server
|
||||
│ ├── package.json
|
||||
│ └── .env # Environment variables
|
||||
├── components/ # React components
|
||||
│ ├── counting/
|
||||
│ │ └── DataManagementPage.tsx # Mortality data management
|
||||
│ ├── CycleTimeline.tsx # Cycle timeline component
|
||||
│ └── ...
|
||||
├── context/ # React context & stores
|
||||
│ ├── AppContext.tsx
|
||||
│ └── stores/
|
||||
│ └── useStaticDataStore.ts # Store with DB integration
|
||||
├── services/
|
||||
│ └── apiService.ts # API client with DB methods
|
||||
├── scripts/ # Database & deployment scripts
|
||||
│ ├── backup-postgres.sh # Backup database
|
||||
│ ├── restore-postgres-local.sh # Restore from backup
|
||||
│ └── fetch-prod-db.sh # Fetch production data
|
||||
├── mockData/ # Mock data for demo mode
|
||||
├── types/ # TypeScript type definitions
|
||||
├── public/ # Static assets
|
||||
├── docker-compose.yml # 3-container orchestration
|
||||
├── package.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 📡 API Documentation
|
||||
|
||||
Base URL: `http://localhost:5001/api`
|
||||
|
||||
### Health Check
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2025-12-22T03:02:20.793Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Cycles Endpoints
|
||||
|
||||
#### Get All Cycles
|
||||
|
||||
```http
|
||||
GET /api/cycles
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "CYCLE-JBW-2025-12-10",
|
||||
"totalDays": 42,
|
||||
"currentDay": 7,
|
||||
"startDate": "2025-12-10",
|
||||
"endDate": "2026-01-20",
|
||||
"chickInWeight": null,
|
||||
"docInCount": 20000,
|
||||
"status": "Active",
|
||||
"createdAt": "2025-12-22 02:59:17",
|
||||
"updatedAt": "2025-12-22 02:59:17"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Active Cycle
|
||||
|
||||
```http
|
||||
GET /api/cycles/active
|
||||
```
|
||||
|
||||
#### Get Cycle by ID
|
||||
|
||||
```http
|
||||
GET /api/cycles/:id
|
||||
```
|
||||
|
||||
#### Update Cycle
|
||||
|
||||
```http
|
||||
PUT /api/cycles/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"totalDays": 42,
|
||||
"currentDay": 7,
|
||||
"startDate": "2025-12-10",
|
||||
"endDate": "2026-01-20",
|
||||
"chickInWeight": 42,
|
||||
"docInCount": 20000,
|
||||
"status": "Active"
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete Cycle
|
||||
|
||||
```http
|
||||
DELETE /api/cycles/:id
|
||||
```
|
||||
|
||||
### Mortality Endpoints
|
||||
|
||||
#### Get All Mortality Records for Cycle
|
||||
|
||||
```http
|
||||
GET /api/mortality/:cycleId
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"cycleId": "CYCLE-JBW-2025-12-10",
|
||||
"day": 0,
|
||||
"mortalityCount": 25,
|
||||
"isEdited": true,
|
||||
"createdAt": "2025-12-22 03:00:00",
|
||||
"updatedAt": "2025-12-22 03:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Mortality Record for Specific Day
|
||||
|
||||
```http
|
||||
GET /api/mortality/:cycleId/:day
|
||||
```
|
||||
|
||||
#### Update/Create Mortality Record
|
||||
|
||||
```http
|
||||
PUT /api/mortality/:cycleId/:day
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mortalityCount": 30
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete Mortality Record (Reset to Default)
|
||||
|
||||
```http
|
||||
DELETE /api/mortality/:cycleId/:day
|
||||
```
|
||||
|
||||
## 🗄️ Database Schema
|
||||
|
||||
### Table: cycles
|
||||
|
||||
Menyimpan informasi siklus produksi.
|
||||
|
||||
| Column | Type | Description |
|
||||
| --------------- | ------- | ----------------------------------------- |
|
||||
| id | TEXT | Primary key, format: CYCLE-JBW-YYYY-MM-DD |
|
||||
| total_days | INTEGER | Total hari dalam siklus (biasanya 42) |
|
||||
| current_day | INTEGER | Hari saat ini dalam siklus |
|
||||
| start_date | TEXT | Tanggal mulai (ISO format: YYYY-MM-DD) |
|
||||
| end_date | TEXT | Tanggal akhir (ISO format: YYYY-MM-DD) |
|
||||
| chick_in_weight | INTEGER | Berat DOC in (gram) |
|
||||
| doc_in_count | INTEGER | Jumlah DOC in (ekor) |
|
||||
| status | TEXT | Status: 'Active', 'Completed', 'Upcoming' |
|
||||
| created_at | TEXT | Timestamp pembuatan |
|
||||
| updated_at | TEXT | Timestamp update terakhir |
|
||||
|
||||
### Table: mortality_records
|
||||
|
||||
Menyimpan data mortalitas harian.
|
||||
|
||||
| Column | Type | Description |
|
||||
| --------------- | ------- | ------------------------------------ |
|
||||
| id | INTEGER | Primary key (auto increment) |
|
||||
| cycle_id | TEXT | Foreign key ke cycles.id |
|
||||
| day | INTEGER | Hari ke berapa dalam siklus |
|
||||
| mortality_count | INTEGER | Jumlah mortalitas hari ini |
|
||||
| is_edited | BOOLEAN | Flag apakah data sudah diedit manual |
|
||||
| created_at | TEXT | Timestamp pembuatan |
|
||||
| updated_at | TEXT | Timestamp update terakhir |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- UNIQUE(cycle_id, day) - Satu cycle hanya bisa punya satu record per hari
|
||||
- FOREIGN KEY cascade delete - Jika cycle dihapus, mortality records ikut terhapus
|
||||
|
||||
### Indexes
|
||||
|
||||
- `idx_mortality_cycle_id` - Index pada cycle_id untuk query cepat
|
||||
- `idx_mortality_day` - Index pada day
|
||||
- `idx_cycles_status` - Index pada status untuk filter cycle aktif
|
||||
- `idx_cycles_start_date` - Index pada start_date untuk sorting
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Port 5001 sudah terpakai
|
||||
|
||||
Jika port 5001 sudah digunakan oleh aplikasi lain (contoh: AirPlay on macOS):
|
||||
|
||||
1. Edit file `backend/.env`:
|
||||
|
||||
```env
|
||||
PORT=5002 # Ganti ke port lain yang tersedia
|
||||
```
|
||||
|
||||
2. Update URL di `services/apiService.ts`:
|
||||
|
||||
```typescript
|
||||
const DB_API_BASE_URL = 'http://localhost:5002/api';
|
||||
```
|
||||
|
||||
3. Restart backend server
|
||||
|
||||
### Port 3001 sudah terpakai
|
||||
|
||||
Vite akan otomatis mencari port lain yang tersedia. Perhatikan output di terminal untuk melihat port yang digunakan.
|
||||
|
||||
### Database tidak terbuat
|
||||
|
||||
Jika database tidak terbuat otomatis:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node database/seed-postgres.js
|
||||
```
|
||||
|
||||
### CORS Error
|
||||
|
||||
Jika frontend tidak bisa mengakses backend, pastikan:
|
||||
|
||||
1. Backend server sudah berjalan
|
||||
2. Port di `backend/server.js` sesuai dengan konfigurasi CORS:
|
||||
|
||||
```javascript
|
||||
origin: ['http://localhost:3001', 'http://localhost:5173'];
|
||||
```
|
||||
|
||||
### Frontend tidak load data dari database
|
||||
|
||||
1. Buka browser console (F12)
|
||||
2. Cek apakah ada error dari API calls
|
||||
3. Pastikan backend server berjalan: `curl http://localhost:5001/health`
|
||||
4. Cek network tab untuk melihat request/response
|
||||
|
||||
### Error "Failed to load cycles from database"
|
||||
|
||||
1. Pastikan backend server sudah berjalan
|
||||
2. Test backend endpoint: `curl http://localhost:5001/api/cycles`
|
||||
3. Cek backend logs untuk melihat error detail
|
||||
|
||||
### Reset Database ke Data Awal
|
||||
|
||||
**For Local PostgreSQL Development:**
|
||||
|
||||
```bash
|
||||
# Stop backend if running, then:
|
||||
./scripts/restore-postgres-local.sh --reset
|
||||
|
||||
# Or manually run seed:
|
||||
cd backend
|
||||
node database/seed-postgres.js
|
||||
```
|
||||
|
||||
**For Docker:**
|
||||
|
||||
```bash
|
||||
# Reset database by removing volume and restarting:
|
||||
docker-compose down -v
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 📝 Fitur Database
|
||||
|
||||
### Auto-Seeding
|
||||
|
||||
Database akan otomatis terisi dengan 4 siklus produksi saat pertama kali server backend dijalankan:
|
||||
|
||||
1. **CYCLE-JBW-2025-05-20** - Completed (20 Mei - 30 Juni 2025)
|
||||
2. **CYCLE-JBW-2025-07-11** - Completed (11 Juli - 21 Agustus 2025)
|
||||
3. **CYCLE-JBW-2025-10-22** - Completed (22 Oktober - 2 Desember 2025)
|
||||
4. **CYCLE-JBW-2025-12-10** - Active (10 Desember 2025 - 20 Januari 2026) ✓
|
||||
|
||||
### Data Persistence
|
||||
|
||||
Semua perubahan data akan tersimpan permanen di PostgreSQL database:
|
||||
|
||||
- ✅ Perubahan tanggal siklus di Linimasa
|
||||
- ✅ Update DOC In dan Berat DOC di pengaturan siklus
|
||||
- ✅ Edit data mortalitas di halaman Manajemen Data
|
||||
- ✅ Reset data mortalitas ke nilai default
|
||||
|
||||
### Backup Database
|
||||
|
||||
**Production/Docker:**
|
||||
|
||||
```bash
|
||||
./scripts/backup-postgres.sh
|
||||
```
|
||||
|
||||
Backups are saved in `./backups/` as `dashboard_db-YYYYMMDD-HHMMSS.sql.gz`
|
||||
|
||||
**Fetch from production server:**
|
||||
|
||||
```bash
|
||||
REMOTE_USER=your-user \
|
||||
REMOTE_HOST=your-host \
|
||||
REMOTE_PROJECT_DIR=/path/to/project \
|
||||
./scripts/fetch-prod-db.sh
|
||||
```
|
||||
|
||||
## 🔐 Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
File: `backend/.env`
|
||||
|
||||
```env
|
||||
PORT=5001
|
||||
NODE_ENV=development
|
||||
|
||||
# PostgreSQL Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=dashboard_user
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=dashboard_db
|
||||
DB_SSL=false
|
||||
```
|
||||
|
||||
### Frontend (.env.local) - Optional
|
||||
|
||||
File: `.env.local` (create if needed)
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5001/api
|
||||
```
|
||||
|
||||
## 🎯 Testing
|
||||
|
||||
### Test Backend API
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:5001/health
|
||||
|
||||
# Get all cycles
|
||||
curl http://localhost:5001/api/cycles
|
||||
|
||||
# Get active cycle
|
||||
curl http://localhost:5001/api/cycles/active
|
||||
|
||||
# Get mortality records
|
||||
curl http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10
|
||||
```
|
||||
|
||||
### Test Frontend
|
||||
|
||||
1. Buka http://localhost:3001 di browser
|
||||
2. Navigasi ke "Linimasa Siklus Produksi"
|
||||
3. Klik "Edit" pada siklus aktif
|
||||
4. Ubah tanggal atau DOC count
|
||||
5. Save dan refresh - data harus tetap tersimpan
|
||||
6. Navigasi ke "Hitung Ayam" > "Manajemen Data"
|
||||
7. Edit nilai mortalitas
|
||||
8. Save dan refresh - data harus tetap tersimpan
|
||||
|
||||
## 📚 Cara Menggunakan Aplikasi
|
||||
|
||||
### 1. Mengelola Siklus Produksi
|
||||
|
||||
**Linimasa Siklus:**
|
||||
|
||||
- Lihat semua siklus produksi (completed, active, upcoming)
|
||||
- Edit tanggal mulai dan akhir
|
||||
- Set DOC In count dan berat DOC
|
||||
- Data tersimpan otomatis ke database
|
||||
|
||||
### 2. Manajemen Data Mortalitas
|
||||
|
||||
**Hitung Ayam > Manajemen Data:**
|
||||
|
||||
- Lihat data mortalitas per hari
|
||||
- Edit nilai mortalitas dengan klik "Edit"
|
||||
- Reset ke nilai default dengan klik tombol reset
|
||||
- Lihat statistik: populasi awal, populasi saat ini, total mortalitas, tingkat mortalitas
|
||||
- Filter data: cari per hari atau tampilkan hanya data yang diedit
|
||||
- Data tersimpan permanen di database
|
||||
|
||||
### 3. Monitoring Standar Performa
|
||||
|
||||
**Standar Performa > Cobb Standard:**
|
||||
|
||||
- Kapasitas kandang otomatis sync dengan DOC In count dari linimasa
|
||||
- Lihat grafik perbandingan berat aktual vs standar Cobb
|
||||
- Analisis FCR (Feed Conversion Ratio)
|
||||
|
||||
## 🚦 Status Proyek
|
||||
|
||||
- ✅ Frontend Dashboard
|
||||
- ✅ Backend API dengan PostgreSQL
|
||||
- ✅ Database Schema & Models
|
||||
- ✅ CRUD Cycles
|
||||
- ✅ CRUD Mortality Records
|
||||
- ✅ Frontend-Backend Integration
|
||||
- ✅ Auto-seeding Data
|
||||
- ✅ Data Persistence
|
||||
- ✅ Docker Deployment (3-container architecture)
|
||||
- ✅ Database Backup & Restore Scripts
|
||||
- ⏳ User Authentication (coming soon)
|
||||
- ⏳ Real-time WebSocket (coming soon)
|
||||
- ⏳ Data Export (CSV/Excel) (coming soon)
|
||||
|
||||
## 🤝 Kontribusi
|
||||
|
||||
Untuk berkontribusi pada proyek ini:
|
||||
|
||||
1. Fork repository
|
||||
2. Buat branch feature (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit perubahan (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push ke branch (`git push origin feature/AmazingFeature`)
|
||||
5. Buat Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
Proyek ini menggunakan lisensi MIT.
|
||||
|
||||
## 👥 Tim Pengembang
|
||||
|
||||
- Development Team - PT Cipta Pola Solusi Prima
|
||||
- Dashboard & AI Integration - 2025
|
||||
|
||||
## 📞 Kontak
|
||||
|
||||
Untuk pertanyaan atau dukungan, silakan hubungi:
|
||||
|
||||
- Email: support@cpsp.id
|
||||
- Website: https://dashboard.cpsp.id
|
||||
|
||||
---
|
||||
|
||||
**Dibuat dengan ❤️ untuk kemajuan peternakan ayam Indonesia**
|
||||
@@ -0,0 +1,106 @@
|
||||
# Daftar Tugas Proyek: Solusi AI Peternakan Ayam
|
||||
|
||||
Dokumen ini melacak tugas-tugas pengembangan untuk proyek ini.
|
||||
|
||||
## Legenda
|
||||
- [x] Selesai
|
||||
- [ ] Belum Selesai
|
||||
- [-] Sedang Dikerjakan
|
||||
- [!] Diblokir
|
||||
|
||||
---
|
||||
|
||||
## Milestone 1: MVP Core Dashboard & Data Display
|
||||
|
||||
### Backend
|
||||
- [x] Setup server & database schema dasar.
|
||||
- [x] Endpoint untuk data KPI statis.
|
||||
- [x] Endpoint untuk data lingkungan (mock).
|
||||
- [x] Endpoint untuk data notifikasi (mock).
|
||||
- [ ] Endpoint untuk otentikasi pengguna.
|
||||
- [ ] Integrasi awal dengan API untuk Laporan Harian.
|
||||
|
||||
### Frontend
|
||||
- [x] Struktur proyek dasar dengan React & TypeScript.
|
||||
- [x] Halaman Login.
|
||||
- [x] Layout utama (Sidebar & Header).
|
||||
- [x] Komponen KPI Card.
|
||||
- [x] Komponen Grafik Lingkungan.
|
||||
- [x] Komponen Peta Kepadatan Kandang.
|
||||
- [x] Komponen Notifikasi.
|
||||
- [x] State management dasar dengan React Context.
|
||||
- [x] Halaman Dashboard Utama.
|
||||
- [-] Manajemen Pengguna & Akses (RBAC).
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2: Fitur Analisis Visual (AI)
|
||||
|
||||
### Backend
|
||||
- [x] Service untuk ingesti stream video dari kamera RTSP.
|
||||
- [x] Model AI: Penghitungan Ayam (integrasi).
|
||||
- [ ] Model AI: Deteksi Mortalitas (integrasi).
|
||||
- [-] Model AI: Penghitungan Karung Pakan (integrasi).
|
||||
- [ ] Model AI: Analisis Perilaku (pengembangan).
|
||||
- [-] Endpoint real-time untuk hasil analisis (WebSockets/SSE).
|
||||
|
||||
### Frontend
|
||||
- [x] Halaman Dashboard: Hitung Ayam.
|
||||
- [x] Halaman Dashboard: Hitung Karung Pakan.
|
||||
- [x] Halaman Dashboard: Analisis Perilaku.
|
||||
- [x] Halaman Dashboard: Pemantauan Berat.
|
||||
- [ ] Visualisasi bounding box pada stream video (mock).
|
||||
- [ ] Update data analitik secara real-time di UI.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3: Manajemen & Aplikasi Mobile
|
||||
|
||||
### Backend
|
||||
- [x] CRUD API untuk Lokasi, Kandang, Lantai.
|
||||
- [x] CRUD API untuk Kamera.
|
||||
- [x] CRUD API untuk Perangkat IoT.
|
||||
- [x] CRUD API untuk Pengguna & Peran.
|
||||
- [ ] Endpoint yang dioptimalkan untuk aplikasi mobile.
|
||||
- [ ] Sistem notifikasi push (Firebase/APNS).
|
||||
|
||||
### Frontend
|
||||
- [x] Halaman Manajemen Struktur (Lokasi/Kandang/Lantai).
|
||||
- [x] Halaman Manajemen Kamera.
|
||||
- [x] Halaman Manajemen Perangkat IoT.
|
||||
- [x] Halaman Manajemen Pengguna & Hak Akses.
|
||||
- [x] Halaman Prototipe Aplikasi Mobile.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 4: Integrasi Rekomendasi AI Lanjutan
|
||||
|
||||
### Backend
|
||||
- [ ] Kembangkan service untuk menghasilkan rekomendasi dinamis berdasarkan data agregat.
|
||||
- [ ] Buat endpoint API untuk menyajikan rekomendasi ke frontend.
|
||||
- [ ] Integrasikan model prediksi (misal: prediksi bobot, prediksi stok pakan).
|
||||
|
||||
### Frontend
|
||||
- [ ] Desain komponen UI untuk menampilkan rekomendasi AI di setiap modul terkait.
|
||||
- [ ] **Dashboard:** Tampilkan "AI Insight of the Day" secara proaktif.
|
||||
- [ ] **Hitung Ayam:** Tampilkan rekomendasi terkait kepadatan & distribusi populasi.
|
||||
- [ ] **Berat Ayam:** Integrasikan rekomendasi prediksi bobot dan keseragaman.
|
||||
- [ ] **Manajemen Pakan:**
|
||||
- [ ] **Hitung Karung:** Tampilkan rekomendasi prediksi inventori dan pemesanan.
|
||||
- [ ] **Pemborosan Pakan:** Tampilkan rekomendasi akar masalah (root cause) dari hotspot pemborosan.
|
||||
- [ ] **Penjadwalan Pakan:** Tampilkan rekomendasi penyesuaian jadwal dinamis berdasarkan perilaku/lingkungan.
|
||||
- [ ] **Analisis Perilaku:** Tampilkan rekomendasi deteksi dini penyakit berdasarkan anomali perilaku.
|
||||
- [ ] **Kunjungan Dokter:** Tampilkan rekomendasi penjadwalan kunjungan preventif berbasis data.
|
||||
- [ ] **Proses OCR:** Tampilkan rekomendasi jika ada anomali data antar dokumen atau dengan data sistem.
|
||||
- [ ] **Manajemen Jaringan:** Tampilkan rekomendasi untuk optimasi perangkat jaringan yang bermasalah.
|
||||
|
||||
---
|
||||
|
||||
## Lain-lain
|
||||
- [ ] Dokumentasi API (Swagger/OpenAPI).
|
||||
- [ ] Setup CI/CD pipeline.
|
||||
- [ ] Penulisan Unit Test & E2E Test.
|
||||
- [ ] Optimasi performa & Core Web Vitals.
|
||||
- [x] Dokumen SRS.
|
||||
- [x] Dokumen PRD.
|
||||
- [x] Daftar Tugas (TODO).
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env.local
|
||||
.env.*.local
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
*.md
|
||||
.git
|
||||
.gitignore
|
||||
@@ -0,0 +1,20 @@
|
||||
# Backend Environment Variables
|
||||
# Copy this file to .env for local development
|
||||
|
||||
# Server port
|
||||
PORT=5001
|
||||
|
||||
# PostgreSQL Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=dashboard_user
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_NAME=dashboard_db
|
||||
DB_SSL=false
|
||||
|
||||
# Connection pool settings (optional)
|
||||
DB_POOL_MIN=2
|
||||
DB_POOL_MAX=10
|
||||
|
||||
# Node environment
|
||||
NODE_ENV=development
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": ["eslint:recommended"],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es2022": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Backend Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (using install instead of ci for flexibility)
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Make startup script executable
|
||||
RUN chmod +x startup.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:5001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||
|
||||
# Start the application using startup script
|
||||
CMD ["sh", "startup.sh"]
|
||||
@@ -0,0 +1,635 @@
|
||||
# Backend API - Dashboard Solusi AI Peternakan Ayam
|
||||
|
||||
Backend server dengan Express.js dan PostgreSQL database untuk menyimpan data siklus produksi dan mortalitas ayam.
|
||||
|
||||
## 📋 Daftar Isi
|
||||
|
||||
- [Teknologi](#teknologi)
|
||||
- [Instalasi](#instalasi)
|
||||
- [Cara Menjalankan](#cara-menjalankan)
|
||||
- [Struktur File](#struktur-file)
|
||||
- [Database](#database)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Development](#development)
|
||||
|
||||
## 🛠 Teknologi
|
||||
|
||||
- **Node.js** v18+
|
||||
- **Express.js** - Web framework
|
||||
- **pg** - PostgreSQL database driver
|
||||
- **cors** - Cross-origin resource sharing
|
||||
- **dotenv** - Environment variables
|
||||
|
||||
## 💿 Instalasi
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
## ▶️ Cara Menjalankan
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Server akan berjalan di http://localhost:5001
|
||||
|
||||
### Production Mode
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### Seeding Database
|
||||
|
||||
```bash
|
||||
npm run seed
|
||||
```
|
||||
|
||||
Akan mengisi database dengan 4 siklus produksi awal.
|
||||
|
||||
## 📁 Struktur File
|
||||
|
||||
```
|
||||
backend/
|
||||
├── database/
|
||||
│ ├── db.js # PostgreSQL connection pool & initialization
|
||||
│ ├── schema.sql # PostgreSQL schema definition
|
||||
│ ├── seed-postgres.js # Data seeding script
|
||||
│ ├── run-migrations.js # Migration runner
|
||||
│ └── migrations/ # Database migration files
|
||||
├── models/
|
||||
│ ├── Cycle.js # Cycle CRUD operations
|
||||
│ └── Mortality.js # Mortality CRUD operations
|
||||
├── routes/
|
||||
│ ├── cycles.js # Cycle API routes
|
||||
│ └── mortality.js # Mortality API routes
|
||||
├── server.js # Express app entry point
|
||||
├── startup.sh # Startup script for Docker
|
||||
├── package.json
|
||||
├── .env # Environment configuration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🗄️ Database
|
||||
|
||||
### Schema
|
||||
|
||||
#### Table: cycles
|
||||
|
||||
```sql
|
||||
CREATE TABLE cycles (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
total_days INTEGER NOT NULL,
|
||||
current_day INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
chick_in_weight INTEGER,
|
||||
doc_in_count INTEGER,
|
||||
status VARCHAR(20) NOT NULL CHECK(status IN ('Completed', 'Active', 'Upcoming')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
#### Table: mortality_records
|
||||
|
||||
```sql
|
||||
CREATE TABLE mortality_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cycle_id VARCHAR(50) NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
mortality_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_edited BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
|
||||
UNIQUE(cycle_id, day)
|
||||
);
|
||||
```
|
||||
|
||||
### Indexes
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_mortality_cycle_id ON mortality_records(cycle_id);
|
||||
CREATE INDEX idx_mortality_day ON mortality_records(day);
|
||||
CREATE INDEX idx_cycles_status ON cycles(status);
|
||||
CREATE INDEX idx_cycles_start_date ON cycles(start_date);
|
||||
```
|
||||
|
||||
See complete schema in [database/schema.sql](database/schema.sql)
|
||||
|
||||
### Initial Data
|
||||
|
||||
Database akan terisi otomatis dengan 4 siklus:
|
||||
|
||||
| Cycle ID | Status | Start Date | End Date | DOC Count |
|
||||
| -------------------- | --------- | ---------- | ---------- | --------- |
|
||||
| CYCLE-JBW-2025-05-20 | Completed | 2025-05-20 | 2025-06-30 | 20,000 |
|
||||
| CYCLE-JBW-2025-07-11 | Completed | 2025-07-11 | 2025-08-21 | 20,000 |
|
||||
| CYCLE-JBW-2025-10-22 | Completed | 2025-10-22 | 2025-12-02 | 20,000 |
|
||||
| CYCLE-JBW-2025-12-10 | Active | 2025-12-10 | 2026-01-20 | 20,000 |
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
Base URL: `http://localhost:5001`
|
||||
|
||||
### Health Check
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2025-12-22T03:02:20.793Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Cycles API
|
||||
|
||||
#### Get All Cycles
|
||||
|
||||
```http
|
||||
GET /api/cycles
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "CYCLE-JBW-2025-12-10",
|
||||
"totalDays": 42,
|
||||
"currentDay": 7,
|
||||
"startDate": "2025-12-10",
|
||||
"endDate": "2026-01-20",
|
||||
"chickInWeight": null,
|
||||
"docInCount": 20000,
|
||||
"status": "Active",
|
||||
"createdAt": "2025-12-22 02:59:17",
|
||||
"updatedAt": "2025-12-22 02:59:17"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Active Cycle
|
||||
|
||||
```http
|
||||
GET /api/cycles/active
|
||||
```
|
||||
|
||||
#### Get Cycle by ID
|
||||
|
||||
```http
|
||||
GET /api/cycles/:id
|
||||
```
|
||||
|
||||
Example: `GET /api/cycles/CYCLE-JBW-2025-12-10`
|
||||
|
||||
#### Create Cycle
|
||||
|
||||
```http
|
||||
POST /api/cycles
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": "CYCLE-JBW-2025-12-10",
|
||||
"totalDays": 42,
|
||||
"currentDay": 0,
|
||||
"startDate": "2025-12-10",
|
||||
"endDate": "2026-01-20",
|
||||
"chickInWeight": 42,
|
||||
"docInCount": 20000,
|
||||
"status": "Active"
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Cycle
|
||||
|
||||
```http
|
||||
PUT /api/cycles/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"totalDays": 42,
|
||||
"currentDay": 7,
|
||||
"startDate": "2025-12-10",
|
||||
"endDate": "2026-01-20",
|
||||
"chickInWeight": 42,
|
||||
"docInCount": 20000,
|
||||
"status": "Active"
|
||||
}
|
||||
```
|
||||
|
||||
#### Delete Cycle
|
||||
|
||||
```http
|
||||
DELETE /api/cycles/:id
|
||||
```
|
||||
|
||||
### Mortality API
|
||||
|
||||
#### Get All Mortality Records for Cycle
|
||||
|
||||
```http
|
||||
GET /api/mortality/:cycleId
|
||||
```
|
||||
|
||||
Example: `GET /api/mortality/CYCLE-JBW-2025-12-10`
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"cycleId": "CYCLE-JBW-2025-12-10",
|
||||
"day": 0,
|
||||
"mortalityCount": 25,
|
||||
"isEdited": true,
|
||||
"createdAt": "2025-12-22 03:00:00",
|
||||
"updatedAt": "2025-12-22 03:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Get Mortality Record for Specific Day
|
||||
|
||||
```http
|
||||
GET /api/mortality/:cycleId/:day
|
||||
```
|
||||
|
||||
Example: `GET /api/mortality/CYCLE-JBW-2025-12-10/5`
|
||||
|
||||
#### Update/Create Mortality Record
|
||||
|
||||
```http
|
||||
PUT /api/mortality/:cycleId/:day
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mortalityCount": 30
|
||||
}
|
||||
```
|
||||
|
||||
- Creates new record if doesn't exist
|
||||
- Updates existing record if exists
|
||||
- Sets `is_edited` flag to true
|
||||
|
||||
#### Delete Mortality Record
|
||||
|
||||
```http
|
||||
DELETE /api/mortality/:cycleId/:day
|
||||
```
|
||||
|
||||
Removes the mortality record, effectively resetting it to default value.
|
||||
|
||||
## 🔐 Environment Variables
|
||||
|
||||
File: `.env`
|
||||
|
||||
```env
|
||||
PORT=5001
|
||||
NODE_ENV=development
|
||||
|
||||
# PostgreSQL Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=dashboard_user
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_NAME=dashboard_db
|
||||
DB_SSL=false
|
||||
|
||||
# Connection pool settings (optional)
|
||||
DB_POOL_MIN=2
|
||||
DB_POOL_MAX=10
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------- | ------------------------------------ | -------------- |
|
||||
| PORT | Server port | 5001 |
|
||||
| NODE_ENV | Environment (development/production) | development |
|
||||
| DB_HOST | PostgreSQL host | localhost |
|
||||
| DB_PORT | PostgreSQL port | 5432 |
|
||||
| DB_USER | PostgreSQL user | dashboard_user |
|
||||
| DB_PASSWORD | PostgreSQL password | (required) |
|
||||
| DB_NAME | Database name | dashboard_db |
|
||||
| DB_SSL | Enable SSL connection | false |
|
||||
| DB_POOL_MIN | Minimum pool connections | 2 |
|
||||
| DB_POOL_MAX | Maximum pool connections | 10 |
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Database Helper Functions
|
||||
|
||||
File: `database/db.js`
|
||||
|
||||
```javascript
|
||||
// Get connection pool and helpers
|
||||
const { pool, query, transaction, toISODate, parseISODate } = require('./database/db');
|
||||
|
||||
// Execute query
|
||||
const result = await query('SELECT * FROM cycles WHERE status = $1', ['Active']);
|
||||
|
||||
// Run transaction
|
||||
await transaction(async (client) => {
|
||||
await client.query('UPDATE cycles SET current_day = $1 WHERE id = $2', [
|
||||
7,
|
||||
'CYCLE-JBW-2025-12-10',
|
||||
]);
|
||||
await client.query('INSERT INTO mortality_records ...');
|
||||
});
|
||||
|
||||
// Date helpers
|
||||
toISODate(new Date()); // Converts Date to YYYY-MM-DD
|
||||
parseISODate('2025-12-10'); // Converts ISO string to Date
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
#### Cycle Model
|
||||
|
||||
File: `models/Cycle.js`
|
||||
|
||||
```javascript
|
||||
const Cycle = require('./models/Cycle');
|
||||
|
||||
// Get all cycles
|
||||
Cycle.getAll();
|
||||
|
||||
// Get cycle by ID
|
||||
Cycle.getById('CYCLE-JBW-2025-12-10');
|
||||
|
||||
// Get active cycle
|
||||
Cycle.getActive();
|
||||
|
||||
// Create new cycle
|
||||
Cycle.create({
|
||||
id: 'CYCLE-JBW-2025-12-10',
|
||||
totalDays: 42,
|
||||
currentDay: 0,
|
||||
startDate: '2025-12-10',
|
||||
endDate: '2026-01-20',
|
||||
docInCount: 20000,
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
// Update cycle
|
||||
Cycle.update('CYCLE-JBW-2025-12-10', {
|
||||
currentDay: 7,
|
||||
// ... other fields
|
||||
});
|
||||
|
||||
// Delete cycle
|
||||
Cycle.delete('CYCLE-JBW-2025-12-10');
|
||||
```
|
||||
|
||||
#### Mortality Model
|
||||
|
||||
File: `models/Mortality.js`
|
||||
|
||||
```javascript
|
||||
const Mortality = require('./models/Mortality');
|
||||
|
||||
// Get all mortality records for a cycle
|
||||
Mortality.getByCycle('CYCLE-JBW-2025-12-10');
|
||||
|
||||
// Get mortality record for specific day
|
||||
Mortality.getByDay('CYCLE-JBW-2025-12-10', 5);
|
||||
|
||||
// Insert or update mortality record
|
||||
Mortality.upsert('CYCLE-JBW-2025-12-10', 5, 30, true);
|
||||
|
||||
// Delete mortality record
|
||||
Mortality.delete('CYCLE-JBW-2025-12-10', 5);
|
||||
```
|
||||
|
||||
### Request Logging
|
||||
|
||||
All requests are logged with timestamp, method, and path:
|
||||
|
||||
```
|
||||
2025-12-22T03:02:20.793Z - GET /health
|
||||
2025-12-22T03:02:22.884Z - GET /api/cycles
|
||||
2025-12-22T03:02:24.985Z - GET /api/cycles/active
|
||||
```
|
||||
|
||||
SQL queries are also logged in development mode.
|
||||
|
||||
### CORS Configuration
|
||||
|
||||
File: `server.js`
|
||||
|
||||
```javascript
|
||||
const corsOptions = {
|
||||
origin: ['http://localhost:3001', 'http://localhost:5173'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
credentials: true,
|
||||
};
|
||||
```
|
||||
|
||||
Add more origins as needed for different environments.
|
||||
|
||||
## 🧪 Testing API
|
||||
|
||||
### Using curl
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:5001/health
|
||||
|
||||
# Get all cycles
|
||||
curl http://localhost:5001/api/cycles
|
||||
|
||||
# Get active cycle
|
||||
curl http://localhost:5001/api/cycles/active
|
||||
|
||||
# Get mortality records
|
||||
curl http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10
|
||||
|
||||
# Create mortality record
|
||||
curl -X PUT http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10/5 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mortalityCount": 30}'
|
||||
|
||||
# Delete mortality record
|
||||
curl -X DELETE http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10/5
|
||||
```
|
||||
|
||||
### Using Postman or Thunder Client
|
||||
|
||||
Import the following collection:
|
||||
|
||||
```json
|
||||
{
|
||||
"info": {
|
||||
"name": "Dashboard Peternakan API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Health Check",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:5001/health"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get All Cycles",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:5001/api/cycles"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 Database Maintenance
|
||||
|
||||
### Backup Database
|
||||
|
||||
**From Docker (Production):**
|
||||
|
||||
```bash
|
||||
# Run backup script (saves to ./backups/)
|
||||
./scripts/backup-postgres.sh
|
||||
```
|
||||
|
||||
**Fetch from Production Server:**
|
||||
|
||||
```bash
|
||||
REMOTE_USER=your-user \
|
||||
REMOTE_HOST=your-host \
|
||||
REMOTE_PROJECT_DIR=/path/to/project \
|
||||
./scripts/fetch-prod-db.sh
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
**Restore to Local:**
|
||||
|
||||
```bash
|
||||
# This will reset local database and restore from backup
|
||||
./scripts/restore-postgres-local.sh --reset backups/dashboard_db-YYYYMMDD-HHMMSS.sql.gz
|
||||
```
|
||||
|
||||
### Reset Database
|
||||
|
||||
**Local Development:**
|
||||
|
||||
```bash
|
||||
npm run seed # Runs seed-postgres.js
|
||||
```
|
||||
|
||||
**Docker:**
|
||||
|
||||
```bash
|
||||
docker-compose down -v # Remove volumes
|
||||
docker-compose up -d # Recreate with fresh data
|
||||
```
|
||||
|
||||
### View Database
|
||||
|
||||
**Using psql CLI:**
|
||||
|
||||
```bash
|
||||
# Connect to local database
|
||||
psql -h localhost -p 5432 -U dashboard_user -d dashboard_db
|
||||
|
||||
# SQL commands
|
||||
\dt # Show all tables
|
||||
\d cycles # Show table schema
|
||||
SELECT * FROM cycles; # Query data
|
||||
SELECT * FROM mortality_records;
|
||||
\q # Exit
|
||||
```
|
||||
|
||||
**Using Docker:**
|
||||
|
||||
```bash
|
||||
docker-compose exec database psql -U dashboard_user -d dashboard_db
|
||||
```
|
||||
|
||||
**Using DBeaver (GUI):**
|
||||
|
||||
- Download from https://dbeaver.io/
|
||||
- Connect to: localhost:5432 (or 15432 for Docker)
|
||||
- Database: dashboard_db
|
||||
- User/Password: from .env file
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
All endpoints return consistent error format:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message here"
|
||||
}
|
||||
```
|
||||
|
||||
HTTP Status Codes:
|
||||
|
||||
- `200` - Success
|
||||
- `201` - Created
|
||||
- `400` - Bad Request (invalid input)
|
||||
- `404` - Not Found
|
||||
- `500` - Internal Server Error
|
||||
|
||||
## 🔒 Security Notes
|
||||
|
||||
- Database credentials should be kept in .env (gitignored)
|
||||
- Foreign keys enforce referential integrity
|
||||
- SQL injection is prevented by using parameterized queries ($1, $2, etc.)
|
||||
- CORS is configured for specific origins only
|
||||
- Input validation on all endpoints
|
||||
- Connection pooling manages database connections efficiently
|
||||
- SSL can be enabled for production (set DB_SSL=true)
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Express.js Documentation](https://expressjs.com/)
|
||||
- [node-postgres (pg) Documentation](https://node-postgres.com/)
|
||||
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
|
||||
- [Local Database Setup Guide](../docs/LOCAL_DATABASE_GUIDE.md)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
When contributing to the backend:
|
||||
|
||||
1. Follow existing code structure
|
||||
2. Add error handling for new endpoints
|
||||
3. Update this README if adding new features
|
||||
4. Test all endpoints before committing
|
||||
5. Keep models thin - business logic in models, HTTP in routes
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For backend-specific issues:
|
||||
|
||||
- Check server logs in console
|
||||
- Verify PostgreSQL is running: `pg_isready` or `docker-compose ps`
|
||||
- Test database connection: `npm run seed`
|
||||
- Check port availability: `lsof -i :5001`
|
||||
- Verify .env configuration (DB_HOST, DB_PORT, credentials)
|
||||
|
||||
---
|
||||
|
||||
**Backend developed by PT Cipta Pola Solusi Prima - 2025**
|
||||
Whitespace-only changes.
@@ -0,0 +1,121 @@
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||
|
||||
// Database configuration
|
||||
const config = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
user: process.env.DB_USER || 'dashboard_user',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'dashboard_db',
|
||||
// Connection pool settings
|
||||
min: parseInt(process.env.DB_POOL_MIN || '2'),
|
||||
max: parseInt(process.env.DB_POOL_MAX || '10'),
|
||||
// Connection timeout
|
||||
connectionTimeoutMillis: 5000,
|
||||
// Idle timeout (30 seconds)
|
||||
idleTimeoutMillis: 30000,
|
||||
// SSL configuration
|
||||
ssl:
|
||||
process.env.DB_SSL === 'true'
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: false,
|
||||
};
|
||||
|
||||
// Create connection pool
|
||||
const pool = new Pool(config);
|
||||
|
||||
// Handle pool errors
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle PostgreSQL client', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
// Initialize database with schema
|
||||
async function initializeDatabase() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
|
||||
await client.query(schema);
|
||||
} catch (error) {
|
||||
console.error('✗ Error initializing database:', error.message);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to convert date to ISO string (YYYY-MM-DD for DATE columns)
|
||||
function toISODate(date) {
|
||||
if (!date) return null;
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
// Helper function to parse ISO date
|
||||
function parseISODate(dateString) {
|
||||
if (!dateString) return null;
|
||||
return new Date(dateString);
|
||||
}
|
||||
|
||||
// Query helper with parameter binding
|
||||
async function query(text, params) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await pool.query(text, params);
|
||||
const duration = Date.now() - start;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
}
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Query error:', error.message);
|
||||
console.error('Query:', text);
|
||||
console.error('Params:', params);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction helper
|
||||
async function transaction(callback) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
async function closePool() {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
// Initialize on first load - store promise so callers can await it
|
||||
const dbReady = initializeDatabase().catch((error) => {
|
||||
console.error('Failed to initialize database:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
query,
|
||||
transaction,
|
||||
toISODate,
|
||||
parseISODate,
|
||||
closePool,
|
||||
dbReady,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration: Add chicken_count and population columns to mortality_records table
|
||||
-- Date: 2025-12-30
|
||||
-- Description: Adds chicken_count (from AI counting) and population (manual override) columns
|
||||
|
||||
-- Add chicken_count column (nullable)
|
||||
ALTER TABLE mortality_records
|
||||
ADD COLUMN IF NOT EXISTS chicken_count INTEGER;
|
||||
|
||||
-- Add population column (nullable)
|
||||
ALTER TABLE mortality_records
|
||||
ADD COLUMN IF NOT EXISTS population INTEGER;
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON COLUMN mortality_records.chicken_count IS 'Chicken count from AI camera counting system';
|
||||
COMMENT ON COLUMN mortality_records.population IS 'Manual population override value';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration tracking table
|
||||
-- This table keeps track of which migrations have been applied
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_schema_migrations_filename ON schema_migrations(filename);
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Migration: Add kandang management
|
||||
-- Date: 2026-02-26
|
||||
-- Description: Adds kandangs table, kandang_cycles junction table, and kandang_id to mortality_records
|
||||
-- Also auto-assigns existing mortality records to a default "Kandang 1"
|
||||
|
||||
-- 1. Create kandangs table
|
||||
CREATE TABLE IF NOT EXISTS kandangs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. Create kandang_cycles junction table (doc_in_count per kandang per cycle)
|
||||
CREATE TABLE IF NOT EXISTS kandang_cycles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
kandang_id INTEGER NOT NULL,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
doc_in_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
|
||||
UNIQUE(kandang_id, cycle_id)
|
||||
);
|
||||
|
||||
-- 3. Add kandang_id column to mortality_records (nullable for backward compat)
|
||||
ALTER TABLE mortality_records
|
||||
ADD COLUMN IF NOT EXISTS kandang_id INTEGER REFERENCES kandangs(id) ON DELETE SET NULL;
|
||||
|
||||
-- 4. Add indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_mortality_kandang_id ON mortality_records(kandang_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_kandang ON kandang_cycles(kandang_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_cycle ON kandang_cycles(cycle_id);
|
||||
|
||||
-- 5. Add updated_at triggers
|
||||
DROP TRIGGER IF EXISTS update_kandangs_updated_at ON kandangs;
|
||||
CREATE TRIGGER update_kandangs_updated_at BEFORE UPDATE ON kandangs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_kandang_cycles_updated_at ON kandang_cycles;
|
||||
CREATE TRIGGER update_kandang_cycles_updated_at BEFORE UPDATE ON kandang_cycles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- 6. Auto-assign existing data: Create default "Kandang 1" if not exists and assign all unassigned mortality records
|
||||
-- Insert default kandang (only if no kandangs exist yet)
|
||||
INSERT INTO kandangs (name)
|
||||
SELECT 'Kandang 1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM kandangs LIMIT 1);
|
||||
|
||||
-- Assign existing mortality records to the first kandang (only records with NULL kandang_id)
|
||||
UPDATE mortality_records
|
||||
SET kandang_id = (SELECT id FROM kandangs ORDER BY id LIMIT 1)
|
||||
WHERE kandang_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM kandangs LIMIT 1);
|
||||
|
||||
-- Create kandang_cycles entries for the default kandang for all cycles that have doc_in_count
|
||||
INSERT INTO kandang_cycles (kandang_id, cycle_id, doc_in_count)
|
||||
SELECT k.id, c.id, COALESCE(c.doc_in_count, 0)
|
||||
FROM kandangs k, cycles c
|
||||
WHERE k.id = (SELECT id FROM kandangs ORDER BY id LIMIT 1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM kandang_cycles kc
|
||||
WHERE kc.kandang_id = k.id AND kc.cycle_id = c.id
|
||||
);
|
||||
|
||||
-- 7. Drop old unique constraint and add new one that includes kandang_id
|
||||
-- Note: The constraint name follows PostgreSQL naming convention
|
||||
ALTER TABLE mortality_records DROP CONSTRAINT IF EXISTS mortality_records_cycle_id_day_key;
|
||||
|
||||
-- Create new unique index that handles kandang_id (using COALESCE for NULL safety)
|
||||
DROP INDEX IF EXISTS idx_mortality_cycle_day_kandang;
|
||||
CREATE UNIQUE INDEX idx_mortality_cycle_day_kandang
|
||||
ON mortality_records(cycle_id, day, COALESCE(kandang_id, -1));
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration: Simplify chicken counting to a single upsert table
|
||||
-- Date: 2026-03-05
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chicken_counting (
|
||||
id SERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
kandang_id INTEGER NOT NULL REFERENCES kandangs(id) ON DELETE CASCADE,
|
||||
filename TEXT,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(date, kandang_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chicken_counting_date ON chicken_counting(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_chicken_counting_kandang_id ON chicken_counting(kandang_id);
|
||||
|
||||
-- Auto-update updated_at trigger
|
||||
CREATE TRIGGER update_chicken_counting_updated_at
|
||||
BEFORE UPDATE ON chicken_counting
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Insert API key for chicken counting endpoint
|
||||
-- Key: cpa_e8cfeeabaa6997a1ecd4239cee6a9cc59cf43f96cfd58463
|
||||
INSERT INTO api_keys (id, key_hash, name, description, is_active, rate_limit)
|
||||
VALUES (
|
||||
'apk_chicken_counting',
|
||||
'7a98758e7e6e4dea9f69a06b98d1f56f9f9838dabb2e56c23214676d17a34cad',
|
||||
'Chicken Counting Service',
|
||||
'API key for chicken counting program to submit count data',
|
||||
true,
|
||||
1000
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration: Add deplesi tracking columns
|
||||
-- Date: 2026-04-19
|
||||
-- Description: Adds panen and keterangan columns to mortality_records for comprehensive daily tracking
|
||||
|
||||
-- Add new columns (safe: idempotent, non-destructive)
|
||||
ALTER TABLE mortality_records
|
||||
ADD COLUMN IF NOT EXISTS panen INTEGER DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS keterangan TEXT;
|
||||
|
||||
-- Optional: Add comment for documentation
|
||||
COMMENT ON COLUMN mortality_records.panen IS 'Daily harvest count (chickens sold/removed)';
|
||||
COMMENT ON COLUMN mortality_records.keterangan IS 'Daily notes or remarks about significant events';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration: Add afkir column
|
||||
-- Date: 2026-04-19
|
||||
-- Description: Adds afkir (culled chickens) column to mortality_records
|
||||
|
||||
-- Add new column (safe: idempotent, non-destructive)
|
||||
ALTER TABLE mortality_records
|
||||
ADD COLUMN IF NOT EXISTS afkir INTEGER DEFAULT 0;
|
||||
|
||||
-- Optional: Add comment for documentation
|
||||
COMMENT ON COLUMN mortality_records.afkir IS 'Daily culled chickens count (removed due to poor quality)';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Migration: Add manual_feed_sack_entries table
|
||||
-- Description: Store manual feed sack count entries separate from IOT camera data
|
||||
-- Date: 2026-04-19
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manual_feed_sack_entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
camera_name VARCHAR(255) NOT NULL,
|
||||
count INTEGER NOT NULL CHECK(count >= 0),
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
|
||||
UNIQUE(cycle_id, day, camera_name)
|
||||
);
|
||||
|
||||
-- Create indexes for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_cycle ON manual_feed_sack_entries(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_day ON manual_feed_sack_entries(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_camera ON manual_feed_sack_entries(camera_name);
|
||||
|
||||
-- Trigger for automatic updated_at timestamp
|
||||
DROP TRIGGER IF EXISTS update_manual_feed_sack_entries_updated_at ON manual_feed_sack_entries;
|
||||
CREATE TRIGGER update_manual_feed_sack_entries_updated_at
|
||||
BEFORE UPDATE ON manual_feed_sack_entries
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Migration: Update manual_feed_sack_entries to use date-based keys instead of cycle-based
|
||||
-- Description: Remove cycle_id and day dependencies, use date + camera_name as unique key
|
||||
-- Date: 2026-04-19
|
||||
|
||||
-- Drop old unique constraint
|
||||
ALTER TABLE manual_feed_sack_entries DROP CONSTRAINT IF EXISTS manual_feed_sack_entries_cycle_id_day_camera_name_key;
|
||||
|
||||
-- Drop foreign key constraint
|
||||
ALTER TABLE manual_feed_sack_entries DROP CONSTRAINT IF EXISTS manual_feed_sack_entries_cycle_id_fkey;
|
||||
|
||||
-- Drop cycle_id and day columns
|
||||
ALTER TABLE manual_feed_sack_entries DROP COLUMN IF EXISTS cycle_id;
|
||||
ALTER TABLE manual_feed_sack_entries DROP COLUMN IF EXISTS day;
|
||||
|
||||
-- Add new unique constraint on date + camera_name (only if it doesn't exist)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'manual_feed_sack_entries_date_camera_key'
|
||||
) THEN
|
||||
ALTER TABLE manual_feed_sack_entries
|
||||
ADD CONSTRAINT manual_feed_sack_entries_date_camera_key UNIQUE (date, camera_name);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Drop old index on cycle_id
|
||||
DROP INDEX IF EXISTS idx_manual_feed_sack_cycle;
|
||||
DROP INDEX IF EXISTS idx_manual_feed_sack_day;
|
||||
|
||||
-- Add index on date for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_manual_feed_sack_date ON manual_feed_sack_entries(date);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration: Add type field to feed_sack_column_config
|
||||
-- Description: Add 'type' field to distinguish inbound vs usage columns
|
||||
-- Date: 2026-05-08
|
||||
-- Author: Claude Code
|
||||
|
||||
-- Update existing configs to add type field
|
||||
-- This uses PostgreSQL's JSONB operators to modify each array element
|
||||
UPDATE feed_sack_column_config
|
||||
SET config = (
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
-- Infer type from cameraName for backward compatibility
|
||||
WHEN elem->>'cameraName' LIKE '%karung_masuk%'
|
||||
THEN elem || '{"type": "inbound"}'
|
||||
ELSE elem || '{"type": "usage"}'
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements(config) AS elem
|
||||
)
|
||||
WHERE config IS NOT NULL AND config != '[]'::jsonb;
|
||||
|
||||
-- Note: This migration is safe because it adds a new field with defaults
|
||||
-- All existing column configs will automatically get 'type' based on camera name pattern
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration: Add keterangan column to manual_feed_sack_entries
|
||||
-- Description: Support per-camera notes/remarks for feed sack counting
|
||||
-- Date: 2026-05-14
|
||||
|
||||
-- Add keterangan column (nullable for backward compatibility)
|
||||
ALTER TABLE manual_feed_sack_entries
|
||||
ADD COLUMN IF NOT EXISTS keterangan TEXT;
|
||||
|
||||
-- Add CHECK constraint for max 200 characters (only if not exists)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'keterangan_max_length'
|
||||
AND conrelid = 'manual_feed_sack_entries'::regclass
|
||||
) THEN
|
||||
ALTER TABLE manual_feed_sack_entries
|
||||
ADD CONSTRAINT keterangan_max_length CHECK (char_length(keterangan) <= 200);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration: Add cycle_feed_initial_balance table
|
||||
-- Description: Store initial feed sack balance (saldo karung awal) per cycle
|
||||
-- Date: 2026-05-26
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cycle_feed_initial_balance (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cycle_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
saldo_awal INTEGER NOT NULL DEFAULT 0 CHECK(saldo_awal >= 0),
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create index for faster lookups by cycle
|
||||
CREATE INDEX IF NOT EXISTS idx_cycle_feed_initial_balance_cycle ON cycle_feed_initial_balance(cycle_id);
|
||||
|
||||
-- Trigger for automatic updated_at timestamp
|
||||
DROP TRIGGER IF EXISTS update_cycle_feed_initial_balance_updated_at ON cycle_feed_initial_balance;
|
||||
CREATE TRIGGER update_cycle_feed_initial_balance_updated_at
|
||||
BEFORE UPDATE ON cycle_feed_initial_balance
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Create AI Insights table for caching AI-generated insights
|
||||
CREATE TABLE IF NOT EXISTS ai_insights (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
kandang_id VARCHAR(255), -- NULL means "all kandangs"
|
||||
insight_text TEXT NOT NULL,
|
||||
version VARCHAR(50) NOT NULL DEFAULT 'v1',
|
||||
generated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insights_cycle_kandang ON ai_insights (cycle_id, kandang_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insights_expires_at ON ai_insights (expires_at);
|
||||
|
||||
-- Add comment to table
|
||||
COMMENT ON TABLE ai_insights IS 'Stores AI-generated insights with 6-hour TTL for cross-device caching';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration 012: Add cage_uuid to kandangs table
|
||||
-- Add cage_uuid field for external scales API integration
|
||||
|
||||
-- Add cage_uuid column (nullable, unique)
|
||||
ALTER TABLE kandangs
|
||||
ADD COLUMN IF NOT EXISTS cage_uuid VARCHAR(36) UNIQUE;
|
||||
|
||||
-- Add comment explaining the field
|
||||
COMMENT ON COLUMN kandangs.cage_uuid IS 'External cage UUID for scales API (dashboard.cpsp.id/api/scales/cage/)';
|
||||
|
||||
-- Seed initial data for existing kandangs
|
||||
-- Kandang Atas gets the existing hardcoded UUID
|
||||
UPDATE kandangs
|
||||
SET cage_uuid = '9f556d2a-af5c-41b0-bcd0-6cea0dfa0fed'
|
||||
WHERE LOWER(name) LIKE '%atas%';
|
||||
|
||||
-- Kandang Bawah and others remain null (to be configured via settings UI)
|
||||
-- This is intentional - admins will configure via IoT Panel settings
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: Add harvest weight tracking to mortality records
|
||||
-- Date: 2026-06-19
|
||||
-- Description: Adds berat_panen to mortality_records so harvest weight persists across reloads
|
||||
|
||||
ALTER TABLE mortality_records
|
||||
ADD COLUMN IF NOT EXISTS berat_panen NUMERIC(10,2) DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN mortality_records.berat_panen IS 'Daily harvest weight in kilograms';
|
||||
@@ -0,0 +1,29 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { query } = require('./db');
|
||||
|
||||
async function runMigration() {
|
||||
try {
|
||||
const migrationPath = path.join(
|
||||
__dirname,
|
||||
'migrations',
|
||||
'add_chicken_count_and_population.sql'
|
||||
);
|
||||
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||
|
||||
// Split by semicolon and execute each statement
|
||||
const statements = sql.split(';').filter((stmt) => stmt.trim());
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
await query(statement);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runMigration();
|
||||
@@ -0,0 +1,67 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { query, closePool, dbReady } = require('./db');
|
||||
|
||||
const MIGRATIONS_DIR = path.join(__dirname, 'migrations');
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
// Wait for schema to be initialized before running migrations
|
||||
await dbReady;
|
||||
|
||||
// Create migrations tracking table if it doesn't exist
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Get list of already applied migrations
|
||||
const applied = await query('SELECT filename FROM schema_migrations ORDER BY filename');
|
||||
const appliedSet = new Set(applied.rows.map((r) => r.filename));
|
||||
|
||||
// Read all .sql files from migrations directory, sorted alphabetically
|
||||
const files = fs
|
||||
.readdirSync(MIGRATIONS_DIR)
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
let migrationsRun = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (appliedSet.has(file)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(MIGRATIONS_DIR, file);
|
||||
const sql = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Execute the entire migration as a single statement block
|
||||
await query(sql);
|
||||
|
||||
// Record the migration
|
||||
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
|
||||
migrationsRun++;
|
||||
}
|
||||
|
||||
if (migrationsRun === 0) {
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
runMigrations()
|
||||
.then(() => closePool())
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => {
|
||||
closePool().then(() => process.exit(1));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
@@ -0,0 +1,77 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { query } = require('./db');
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_schema_migrations_filename
|
||||
ON schema_migrations(filename)
|
||||
`);
|
||||
|
||||
// Get all migration files
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
|
||||
if (!fs.existsSync(migrationsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(migrationsDir)
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort(); // Run in alphabetical order (001_, 002_, etc.)
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let appliedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// Process each migration file
|
||||
for (const file of files) {
|
||||
// Check if migration has already been applied
|
||||
const checkResult = await query('SELECT * FROM schema_migrations WHERE filename = $1', [
|
||||
file,
|
||||
]);
|
||||
|
||||
if (checkResult.rows.length > 0) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply new migration
|
||||
try {
|
||||
const filePath = path.join(migrationsDir, file);
|
||||
const sql = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// Execute migration SQL
|
||||
await query(sql);
|
||||
|
||||
// Record migration as applied
|
||||
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
|
||||
appliedCount++;
|
||||
} catch (error) {
|
||||
console.error(` ❌ ${file} - FAILED`);
|
||||
console.error(` Error: ${error.message}`);
|
||||
throw error; // Stop on first error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n' + '='.repeat(50));
|
||||
console.error('❌ Migration failed!');
|
||||
console.error('='.repeat(50));
|
||||
console.error(error);
|
||||
console.error('\n⚠️ Server startup aborted due to migration failure\n');
|
||||
throw error; // Re-throw to prevent server from starting
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
@@ -0,0 +1,323 @@
|
||||
-- PostgreSQL Schema for Chicken Farm Management System
|
||||
-- This file is automatically executed on database initialization
|
||||
|
||||
-- =============================================================================
|
||||
-- CORE TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- Cycles table
|
||||
CREATE TABLE IF NOT EXISTS cycles (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
total_days INTEGER NOT NULL,
|
||||
current_day INTEGER NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
chick_in_weight INTEGER,
|
||||
doc_in_count INTEGER,
|
||||
status VARCHAR(50) NOT NULL CHECK(status IN ('Completed', 'Active', 'Upcoming')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Kandangs table (chicken houses for mortality/population tracking)
|
||||
CREATE TABLE IF NOT EXISTS kandangs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cage_uuid VARCHAR(36) UNIQUE, -- External cage UUID for scales API integration
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Kandang-Cycles junction table (doc_in_count per kandang per cycle)
|
||||
CREATE TABLE IF NOT EXISTS kandang_cycles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
kandang_id INTEGER NOT NULL,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
doc_in_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (kandang_id) REFERENCES kandangs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
|
||||
UNIQUE(kandang_id, cycle_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_kandang ON kandang_cycles(kandang_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kandang_cycles_cycle ON kandang_cycles(cycle_id);
|
||||
|
||||
-- Mortality records table
|
||||
CREATE TABLE IF NOT EXISTS mortality_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
mortality_count INTEGER NOT NULL DEFAULT 0,
|
||||
chicken_count INTEGER,
|
||||
population INTEGER,
|
||||
kandang_id INTEGER REFERENCES kandangs(id) ON DELETE SET NULL,
|
||||
is_edited BOOLEAN DEFAULT FALSE,
|
||||
afkir INTEGER DEFAULT 0,
|
||||
panen INTEGER DEFAULT 0,
|
||||
berat_panen NUMERIC(10,2) DEFAULT 0,
|
||||
keterangan TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Unique index for mortality records (supports per-kandang tracking)
|
||||
-- Wrapped in DO block: on existing DBs, kandang_id may not exist yet (added by migration 002)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'mortality_records' AND column_name = 'kandang_id'
|
||||
) THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_mortality_cycle_day_kandang') THEN
|
||||
CREATE UNIQUE INDEX idx_mortality_cycle_day_kandang
|
||||
ON mortality_records(cycle_id, day, COALESCE(kandang_id, -1));
|
||||
END IF;
|
||||
ELSE
|
||||
-- Fallback: ensure old unique constraint exists for pre-migration state
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'mortality_records_cycle_id_day_key'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes WHERE indexname = 'idx_mortality_cycle_day_kandang'
|
||||
) THEN
|
||||
ALTER TABLE mortality_records ADD CONSTRAINT mortality_records_cycle_id_day_key UNIQUE (cycle_id, day);
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_mortality_cycle_id ON mortality_records(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mortality_day ON mortality_records(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_status ON cycles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cycles_start_date ON cycles(start_date);
|
||||
|
||||
-- kandang_id index (only if column exists - added by migration 002 on existing DBs)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'mortality_records' AND column_name = 'kandang_id'
|
||||
) THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_mortality_kandang_id') THEN
|
||||
CREATE INDEX idx_mortality_kandang_id ON mortality_records(kandang_id);
|
||||
END IF;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- CHICKEN COUNTING API TABLES
|
||||
-- =============================================================================
|
||||
|
||||
-- API Keys for authentication
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
key_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
created_by VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP WITH TIME ZONE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
rate_limit INTEGER DEFAULT 1000,
|
||||
allowed_ips TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
|
||||
|
||||
-- Location hierarchy: Locations (top level)
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Location hierarchy: Coops (buildings within locations)
|
||||
CREATE TABLE IF NOT EXISTS coops (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
location_id VARCHAR(255) NOT NULL,
|
||||
capacity INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_coops_location ON coops(location_id);
|
||||
|
||||
-- Location hierarchy: Floors (levels within coops)
|
||||
CREATE TABLE IF NOT EXISTS floors (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
coop_id VARCHAR(255) NOT NULL,
|
||||
floor_number INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (coop_id) REFERENCES coops(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_floors_coop ON floors(coop_id);
|
||||
|
||||
-- Location hierarchy: Cameras (AI/CV cameras on floors)
|
||||
CREATE TABLE IF NOT EXISTS cameras (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
camera_type VARCHAR(50) NOT NULL CHECK(camera_type IN ('STANDARD', 'INFRARED', 'THERMAL', 'THREE_D')) DEFAULT 'STANDARD',
|
||||
rtsp_url TEXT,
|
||||
floor_id VARCHAR(255),
|
||||
model VARCHAR(255),
|
||||
status VARCHAR(50) NOT NULL CHECK(status IN ('online', 'offline', 'error')) DEFAULT 'offline',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (floor_id) REFERENCES floors(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cameras_floor ON cameras(floor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cameras_status ON cameras(status);
|
||||
|
||||
-- Chicken count records from AI cameras
|
||||
CREATE TABLE IF NOT EXISTS chicken_count_records (
|
||||
id SERIAL PRIMARY KEY,
|
||||
camera_id VARCHAR(255) NOT NULL,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL CHECK(count >= 0),
|
||||
confidence_score REAL CHECK(confidence_score >= 0 AND confidence_score <= 1),
|
||||
processing_status VARCHAR(50) NOT NULL CHECK(processing_status IN ('success', 'processing', 'failed')) DEFAULT 'success',
|
||||
source_type VARCHAR(50) NOT NULL CHECK(source_type IN ('ai_camera', 'manual', 'estimated')) DEFAULT 'ai_camera',
|
||||
image_url TEXT,
|
||||
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
is_verified BOOLEAN DEFAULT FALSE,
|
||||
verified_by VARCHAR(255),
|
||||
verified_at TIMESTAMP WITH TIME ZONE,
|
||||
notes TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (camera_id) REFERENCES cameras(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
|
||||
UNIQUE(camera_id, cycle_id, day, recorded_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_count_camera ON chicken_count_records(camera_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_cycle ON chicken_count_records(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_day ON chicken_count_records(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_recorded_at ON chicken_count_records(recorded_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_cycle_day ON chicken_count_records(cycle_id, day);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_status ON chicken_count_records(processing_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_verified ON chicken_count_records(is_verified);
|
||||
CREATE INDEX IF NOT EXISTS idx_count_metadata ON chicken_count_records USING GIN(metadata);
|
||||
|
||||
-- Daily aggregated counts for performance
|
||||
CREATE TABLE IF NOT EXISTS daily_aggregated_counts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cycle_id VARCHAR(255) NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
average_confidence REAL,
|
||||
camera_count INTEGER,
|
||||
recorded_date DATE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (cycle_id) REFERENCES cycles(id) ON DELETE CASCADE,
|
||||
UNIQUE(cycle_id, day, recorded_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_agg_cycle ON daily_aggregated_counts(cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_agg_day ON daily_aggregated_counts(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_daily_agg_date ON daily_aggregated_counts(recorded_date);
|
||||
|
||||
-- =============================================================================
|
||||
-- FEED SACK COLUMN CONFIGURATION
|
||||
-- =============================================================================
|
||||
|
||||
-- Single-row table storing the column config as JSONB
|
||||
CREATE TABLE IF NOT EXISTS feed_sack_column_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
config JSONB NOT NULL DEFAULT '[]',
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- FUNCTIONS AND TRIGGERS
|
||||
-- =============================================================================
|
||||
|
||||
-- Function to automatically update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Apply update_updated_at trigger to all tables with updated_at column
|
||||
DROP TRIGGER IF EXISTS update_cycles_updated_at ON cycles;
|
||||
CREATE TRIGGER update_cycles_updated_at BEFORE UPDATE ON cycles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_mortality_records_updated_at ON mortality_records;
|
||||
CREATE TRIGGER update_mortality_records_updated_at BEFORE UPDATE ON mortality_records
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_kandangs_updated_at ON kandangs;
|
||||
CREATE TRIGGER update_kandangs_updated_at BEFORE UPDATE ON kandangs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_kandang_cycles_updated_at ON kandang_cycles;
|
||||
CREATE TRIGGER update_kandang_cycles_updated_at BEFORE UPDATE ON kandang_cycles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_locations_updated_at ON locations;
|
||||
CREATE TRIGGER update_locations_updated_at BEFORE UPDATE ON locations
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_coops_updated_at ON coops;
|
||||
CREATE TRIGGER update_coops_updated_at BEFORE UPDATE ON coops
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_floors_updated_at ON floors;
|
||||
CREATE TRIGGER update_floors_updated_at BEFORE UPDATE ON floors
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_cameras_updated_at ON cameras;
|
||||
CREATE TRIGGER update_cameras_updated_at BEFORE UPDATE ON cameras
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_chicken_count_records_updated_at ON chicken_count_records;
|
||||
CREATE TRIGGER update_chicken_count_records_updated_at BEFORE UPDATE ON chicken_count_records
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_daily_aggregated_counts_updated_at ON daily_aggregated_counts;
|
||||
CREATE TRIGGER update_daily_aggregated_counts_updated_at BEFORE UPDATE ON daily_aggregated_counts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- =============================================================================
|
||||
-- AUDIT LOG TABLE
|
||||
-- =============================================================================
|
||||
|
||||
-- Audit logs table for tracking all data changes
|
||||
-- Note: Timestamps are stored in UTC (TIMESTAMP WITH TIME ZONE)
|
||||
-- Frontend converts to WIB (UTC+7) for display
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
table_name VARCHAR(50) NOT NULL,
|
||||
record_id VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(20) NOT NULL CHECK(action IN ('CREATE', 'UPDATE', 'DELETE')),
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
changed_fields TEXT[],
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -- Stored in UTC
|
||||
);
|
||||
|
||||
-- Indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record ON audit_logs(table_name, record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_created ON audit_logs(table_name, created_at DESC);
|
||||
@@ -0,0 +1,260 @@
|
||||
const { query, toISODate, closePool, dbReady } = require('./db');
|
||||
const Cycle = require('../models/Cycle');
|
||||
const Camera = require('../models/Camera');
|
||||
const ApiKey = require('../models/ApiKey');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Helper function to hash API keys
|
||||
function hashApiKey(key) {
|
||||
return crypto.createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
// Helper function to generate API key
|
||||
function generateApiKey() {
|
||||
const randomBytes = crypto.randomBytes(24);
|
||||
return 'cpa_' + randomBytes.toString('hex');
|
||||
}
|
||||
|
||||
// Seed data - minimal cycles with no assumed data
|
||||
const cycles = [
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-05-20',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-05-20'),
|
||||
status: 'Completed',
|
||||
docInCount: null,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-07-11',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-07-11'),
|
||||
status: 'Completed',
|
||||
docInCount: null,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-10-22',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-10-22'),
|
||||
status: 'Completed',
|
||||
docInCount: null,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-12-10',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-12-10'),
|
||||
status: 'Completed',
|
||||
docInCount: null,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2026-02-09',
|
||||
totalDays: 42,
|
||||
currentDay: 2,
|
||||
startDate: new Date('2026-02-09'),
|
||||
status: 'Active',
|
||||
docInCount: 20000,
|
||||
chickInWeight: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Location hierarchy seed data
|
||||
const locations = [
|
||||
{
|
||||
id: 'loc1',
|
||||
name: 'Peternakan Pusat, Jawa Barat',
|
||||
address: 'Jl. Peternakan No. 123, Bandung, Jawa Barat',
|
||||
},
|
||||
];
|
||||
|
||||
const coops = [
|
||||
{
|
||||
id: 'coop1',
|
||||
name: 'Kandang A (Broiler)',
|
||||
locationId: 'loc1',
|
||||
capacity: 15000,
|
||||
},
|
||||
{
|
||||
id: 'coop2',
|
||||
name: 'Kandang B (Layer)',
|
||||
locationId: 'loc1',
|
||||
capacity: 12000,
|
||||
},
|
||||
];
|
||||
|
||||
const floors = [
|
||||
{
|
||||
id: 'floor1',
|
||||
name: 'Lantai 1',
|
||||
coopId: 'coop1',
|
||||
floorNumber: 1,
|
||||
},
|
||||
{
|
||||
id: 'floor2',
|
||||
name: 'Lantai 2',
|
||||
coopId: 'coop1',
|
||||
floorNumber: 2,
|
||||
},
|
||||
{
|
||||
id: 'floor3',
|
||||
name: 'Lantai 1',
|
||||
coopId: 'coop2',
|
||||
floorNumber: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const cameras = [
|
||||
{
|
||||
id: 'cam1',
|
||||
name: 'Kamera Sudut Kanan Zona A',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor1',
|
||||
model: 'HIK-2MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam2',
|
||||
name: 'Kamera Sudut Kiri Zona A',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor1',
|
||||
model: 'HIK-2MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam3',
|
||||
name: 'Kamera Tengah Zona B',
|
||||
cameraType: 'INFRARED',
|
||||
floorId: 'floor2',
|
||||
model: 'HIK-4MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam4',
|
||||
name: 'Kamera Pintu Masuk',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor2',
|
||||
model: 'HIK-2MP',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam5',
|
||||
name: 'Kamera Zona Layer 1',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor3',
|
||||
model: 'HIK-2MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
];
|
||||
|
||||
async function seedDatabase() {
|
||||
try {
|
||||
// Wait for schema to be initialized before querying
|
||||
await dbReady;
|
||||
|
||||
// Check if cycles already exist
|
||||
const existingCycles = await query('SELECT COUNT(*) as count FROM cycles', []);
|
||||
const cycleCount = parseInt(existingCycles.rows[0].count);
|
||||
|
||||
if (cycleCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert locations (skip if already exist)
|
||||
for (const loc of locations) {
|
||||
await query(
|
||||
'INSERT INTO locations (id, name, address) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING',
|
||||
[loc.id, loc.name, loc.address || null]
|
||||
);
|
||||
}
|
||||
|
||||
// Insert coops (skip if already exist)
|
||||
for (const coop of coops) {
|
||||
await query(
|
||||
'INSERT INTO coops (id, name, location_id, capacity) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING',
|
||||
[coop.id, coop.name, coop.locationId, coop.capacity || null]
|
||||
);
|
||||
}
|
||||
|
||||
// Insert floors (skip if already exist)
|
||||
for (const floor of floors) {
|
||||
await query(
|
||||
'INSERT INTO floors (id, name, coop_id, floor_number) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING',
|
||||
[floor.id, floor.name, floor.coopId, floor.floorNumber || null]
|
||||
);
|
||||
}
|
||||
for (const cam of cameras) {
|
||||
await Camera.create({
|
||||
id: cam.id,
|
||||
name: cam.name,
|
||||
cameraType: cam.cameraType,
|
||||
floorId: cam.floorId || null,
|
||||
model: cam.model || null,
|
||||
status: cam.status,
|
||||
});
|
||||
}
|
||||
for (const cycle of cycles) {
|
||||
const endDate = new Date(cycle.startDate);
|
||||
endDate.setDate(endDate.getDate() + cycle.totalDays - 1);
|
||||
|
||||
await Cycle.create({
|
||||
id: cycle.id,
|
||||
totalDays: cycle.totalDays,
|
||||
currentDay: cycle.currentDay,
|
||||
startDate: cycle.startDate,
|
||||
endDate: endDate,
|
||||
chickInWeight: cycle.chickInWeight,
|
||||
docInCount: cycle.docInCount,
|
||||
status: cycle.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Display seeded cycles
|
||||
const seededCycles = await Cycle.getAll();
|
||||
seededCycles.forEach((c) => {});
|
||||
const devKey = generateApiKey();
|
||||
const keyHash = hashApiKey(devKey);
|
||||
const keyId = 'apk_dev_' + Date.now();
|
||||
|
||||
await query(
|
||||
`INSERT INTO api_keys (id, key_hash, name, description, created_by, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
keyId,
|
||||
keyHash,
|
||||
'Development Key',
|
||||
'Auto-generated key for testing and development',
|
||||
'seed-script',
|
||||
1,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('✗ Error seeding database:', error.message);
|
||||
console.error(error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run seed if this file is executed directly
|
||||
if (require.main === module) {
|
||||
seedDatabase()
|
||||
.then(() => {
|
||||
return closePool();
|
||||
})
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
closePool().then(() => {
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { seedDatabase };
|
||||
@@ -0,0 +1,262 @@
|
||||
const { db, toISODate } = require('./db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Helper function to hash API keys
|
||||
function hashApiKey(key) {
|
||||
return crypto.createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
// Helper function to generate API key
|
||||
function generateApiKey() {
|
||||
const randomBytes = crypto.randomBytes(24);
|
||||
return 'cpa_' + randomBytes.toString('hex');
|
||||
}
|
||||
|
||||
// Seed data from mockData/cycleData.ts
|
||||
const cycles = [
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-05-20',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-05-20'),
|
||||
status: 'Completed',
|
||||
docInCount: 20000,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-07-11',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-07-11'),
|
||||
status: 'Completed',
|
||||
docInCount: 20000,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-10-22',
|
||||
totalDays: 42,
|
||||
currentDay: 42,
|
||||
startDate: new Date('2025-10-22'),
|
||||
status: 'Completed',
|
||||
docInCount: 20000,
|
||||
chickInWeight: null,
|
||||
},
|
||||
{
|
||||
id: 'CYCLE-JBW-2025-12-10',
|
||||
totalDays: 42,
|
||||
currentDay: 7,
|
||||
startDate: new Date('2025-12-10'),
|
||||
status: 'Active',
|
||||
docInCount: 20000,
|
||||
chickInWeight: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Location hierarchy seed data
|
||||
const locations = [
|
||||
{
|
||||
id: 'loc1',
|
||||
name: 'Peternakan Pusat, Jawa Barat',
|
||||
address: 'Jl. Peternakan No. 123, Bandung, Jawa Barat',
|
||||
},
|
||||
];
|
||||
|
||||
const coops = [
|
||||
{
|
||||
id: 'coop1',
|
||||
name: 'Kandang A (Broiler)',
|
||||
locationId: 'loc1',
|
||||
capacity: 15000,
|
||||
},
|
||||
{
|
||||
id: 'coop2',
|
||||
name: 'Kandang B (Layer)',
|
||||
locationId: 'loc1',
|
||||
capacity: 12000,
|
||||
},
|
||||
];
|
||||
|
||||
const floors = [
|
||||
{
|
||||
id: 'floor1',
|
||||
name: 'Lantai 1',
|
||||
coopId: 'coop1',
|
||||
floorNumber: 1,
|
||||
},
|
||||
{
|
||||
id: 'floor2',
|
||||
name: 'Lantai 2',
|
||||
coopId: 'coop1',
|
||||
floorNumber: 2,
|
||||
},
|
||||
{
|
||||
id: 'floor3',
|
||||
name: 'Lantai 1',
|
||||
coopId: 'coop2',
|
||||
floorNumber: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const cameras = [
|
||||
{
|
||||
id: 'cam1',
|
||||
name: 'Kamera Sudut Kanan Zona A',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor1',
|
||||
model: 'HIK-2MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam2',
|
||||
name: 'Kamera Sudut Kiri Zona A',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor1',
|
||||
model: 'HIK-2MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam3',
|
||||
name: 'Kamera Tengah Zona B',
|
||||
cameraType: 'INFRARED',
|
||||
floorId: 'floor2',
|
||||
model: 'HIK-4MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam4',
|
||||
name: 'Kamera Pintu Masuk',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor2',
|
||||
model: 'HIK-2MP',
|
||||
status: 'online',
|
||||
},
|
||||
{
|
||||
id: 'cam5',
|
||||
name: 'Kamera Zona Layer 1',
|
||||
cameraType: 'STANDARD',
|
||||
floorId: 'floor3',
|
||||
model: 'HIK-2MP-IR',
|
||||
status: 'online',
|
||||
},
|
||||
];
|
||||
|
||||
function seedDatabase() {
|
||||
try {
|
||||
// Check if cycles already exist
|
||||
const existingCycles = db.prepare('SELECT COUNT(*) as count FROM cycles').get();
|
||||
|
||||
if (existingCycles.count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert cycles
|
||||
const insertCycle = db.prepare(`
|
||||
INSERT INTO cycles (id, total_days, current_day, start_date, end_date, chick_in_weight, doc_in_count, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction((cycles) => {
|
||||
for (const cycle of cycles) {
|
||||
const endDate = new Date(cycle.startDate);
|
||||
endDate.setDate(endDate.getDate() + cycle.totalDays - 1);
|
||||
|
||||
insertCycle.run(
|
||||
cycle.id,
|
||||
cycle.totalDays,
|
||||
cycle.currentDay,
|
||||
toISODate(cycle.startDate),
|
||||
toISODate(endDate),
|
||||
cycle.chickInWeight,
|
||||
cycle.docInCount,
|
||||
cycle.status
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(cycles);
|
||||
|
||||
// Display seeded data
|
||||
const seededCycles = db.prepare('SELECT * FROM cycles ORDER BY start_date').all();
|
||||
seededCycles.forEach((c) => {});
|
||||
|
||||
// Insert locations
|
||||
const insertLocation = db.prepare(`
|
||||
INSERT INTO locations (id, name, address) VALUES (?, ?, ?)
|
||||
`);
|
||||
const seedLocations = db.transaction((locs) => {
|
||||
for (const loc of locs) {
|
||||
insertLocation.run(loc.id, loc.name, loc.address || null);
|
||||
}
|
||||
});
|
||||
seedLocations(locations);
|
||||
|
||||
// Insert coops
|
||||
const insertCoop = db.prepare(`
|
||||
INSERT INTO coops (id, name, location_id, capacity) VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const seedCoops = db.transaction((coopList) => {
|
||||
for (const coop of coopList) {
|
||||
insertCoop.run(coop.id, coop.name, coop.locationId, coop.capacity || null);
|
||||
}
|
||||
});
|
||||
seedCoops(coops);
|
||||
|
||||
// Insert floors
|
||||
const insertFloor = db.prepare(`
|
||||
INSERT INTO floors (id, name, coop_id, floor_number) VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const seedFloors = db.transaction((floorList) => {
|
||||
for (const floor of floorList) {
|
||||
insertFloor.run(floor.id, floor.name, floor.coopId, floor.floorNumber || null);
|
||||
}
|
||||
});
|
||||
seedFloors(floors);
|
||||
|
||||
// Insert cameras
|
||||
const insertCamera = db.prepare(`
|
||||
INSERT INTO cameras (id, name, camera_type, floor_id, model, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const seedCameras = db.transaction((camList) => {
|
||||
for (const cam of camList) {
|
||||
insertCamera.run(
|
||||
cam.id,
|
||||
cam.name,
|
||||
cam.cameraType,
|
||||
cam.floorId || null,
|
||||
cam.model || null,
|
||||
cam.status
|
||||
);
|
||||
}
|
||||
});
|
||||
seedCameras(cameras);
|
||||
const devKey = generateApiKey();
|
||||
const keyHash = hashApiKey(devKey);
|
||||
const keyId = 'apk_dev_' + Date.now();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO api_keys (id, key_hash, name, description, created_by, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
keyId,
|
||||
keyHash,
|
||||
'Development Key',
|
||||
'Auto-generated key for testing and development',
|
||||
'seed-script',
|
||||
1
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('✗ Error seeding database:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run seed if this file is executed directly
|
||||
if (require.main === module) {
|
||||
seedDatabase();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
module.exports = { seedDatabase };
|
||||
@@ -0,0 +1,93 @@
|
||||
const AuditLog = require('../models/AuditLog');
|
||||
|
||||
/**
|
||||
* Middleware to log data changes to audit_logs table
|
||||
*
|
||||
* Usage:
|
||||
* const { captureAuditLog } = require('../middleware/auditLogger');
|
||||
*
|
||||
* router.put('/endpoint/:id', async (req, res) => {
|
||||
* const oldData = await Model.getById(id); // Fetch current data before update
|
||||
*
|
||||
* // Perform update
|
||||
* const newData = await Model.update(id, req.body);
|
||||
*
|
||||
* // Log the change
|
||||
* await captureAuditLog('table_name', id, 'UPDATE', oldData, newData);
|
||||
*
|
||||
* res.json({ success: true, data: newData });
|
||||
* });
|
||||
*/
|
||||
|
||||
/**
|
||||
* Capture audit log for a data change
|
||||
* @param {string} tableName - Name of the table being modified
|
||||
* @param {string|number} recordId - ID of the record being modified
|
||||
* @param {string} action - Action type: CREATE, UPDATE, DELETE
|
||||
* @param {Object} oldValues - Previous values (null for CREATE)
|
||||
* @param {Object} newValues - New values (null for DELETE)
|
||||
* @returns {Promise<Object>} Created audit log entry
|
||||
*/
|
||||
async function captureAuditLog(tableName, recordId, action, oldValues = null, newValues = null) {
|
||||
try {
|
||||
// Determine which fields changed
|
||||
const changedFields = [];
|
||||
|
||||
if (action === 'UPDATE' && oldValues && newValues) {
|
||||
// Compare old and new values to find changed fields
|
||||
for (const key in newValues) {
|
||||
if (oldValues.hasOwnProperty(key)) {
|
||||
// Handle different types of values
|
||||
const oldVal = oldValues[key];
|
||||
const newVal = newValues[key];
|
||||
|
||||
// Deep comparison for objects/arrays, simple comparison otherwise
|
||||
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
||||
changedFields.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only log if there are actual changes (for UPDATE) or always for CREATE/DELETE
|
||||
if (action !== 'UPDATE' || changedFields.length > 0) {
|
||||
return await AuditLog.create(
|
||||
tableName,
|
||||
recordId,
|
||||
action,
|
||||
oldValues,
|
||||
newValues,
|
||||
changedFields
|
||||
);
|
||||
}
|
||||
|
||||
return null; // No changes to log
|
||||
} catch (error) {
|
||||
// Log error but don't fail the main operation
|
||||
console.error('Error capturing audit log:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract only relevant fields for logging
|
||||
* (excludes timestamps and internal fields)
|
||||
* @param {Object} data - Data object
|
||||
* @param {string[]} excludeFields - Fields to exclude (default: created_at, updated_at)
|
||||
* @returns {Object} Filtered data object
|
||||
*/
|
||||
function filterAuditFields(data, excludeFields = ['created_at', 'updated_at']) {
|
||||
if (!data) return null;
|
||||
|
||||
const filtered = { ...data };
|
||||
excludeFields.forEach(field => {
|
||||
delete filtered[field];
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureAuditLog,
|
||||
filterAuditFields
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
const crypto = require('crypto');
|
||||
const { query } = require('../database/db');
|
||||
|
||||
/**
|
||||
* Hash an API key using SHA-256
|
||||
*/
|
||||
function hashApiKey(key) {
|
||||
return crypto.createHash('sha256').update(key).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API key with 'cpa_' prefix
|
||||
*/
|
||||
function generateApiKey() {
|
||||
const randomBytes = crypto.randomBytes(24);
|
||||
return 'cpa_' + randomBytes.toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate API key from X-API-Key header
|
||||
*/
|
||||
async function validateApiKey(req, res, next) {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'API key is required. Provide X-API-Key header.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!apiKey.startsWith('cpa_')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid API key format'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const keyHash = hashApiKey(apiKey);
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, name, is_active, expires_at, rate_limit, allowed_ips
|
||||
FROM api_keys
|
||||
WHERE key_hash = $1`,
|
||||
[keyHash]
|
||||
);
|
||||
|
||||
const keyRecord = result.rows[0];
|
||||
|
||||
if (!keyRecord) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid API key'
|
||||
});
|
||||
}
|
||||
|
||||
if (!keyRecord.is_active) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'API key is inactive'
|
||||
});
|
||||
}
|
||||
|
||||
if (keyRecord.expires_at) {
|
||||
const expiryDate = new Date(keyRecord.expires_at);
|
||||
if (expiryDate < new Date()) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'API key has expired'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (keyRecord.allowed_ips) {
|
||||
const allowedIps = keyRecord.allowed_ips.split(',').map(ip => ip.trim());
|
||||
const clientIp = req.ip || req.connection.remoteAddress;
|
||||
if (!allowedIps.includes(clientIp)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'IP address not allowed for this API key'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update last_used_at
|
||||
await query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1', [keyRecord.id]);
|
||||
|
||||
req.apiKey = {
|
||||
id: keyRecord.id,
|
||||
name: keyRecord.name,
|
||||
rateLimit: keyRecord.rate_limit
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('API key validation error:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Authentication error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateApiKey,
|
||||
generateApiKey,
|
||||
hashApiKey
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Error Handler Middleware
|
||||
* Sanitizes errors before sending to client (security best practice)
|
||||
*/
|
||||
|
||||
function errorHandler(err, req, res, _next) {
|
||||
// Log full error internally (with stack trace)
|
||||
console.error('[ERROR]', {
|
||||
timestamp: new Date().toISOString(),
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined,
|
||||
});
|
||||
|
||||
// Determine error type
|
||||
const statusCode = err.statusCode || 500;
|
||||
const isClientError = statusCode >= 400 && statusCode < 500;
|
||||
|
||||
// Send sanitized response to client
|
||||
// IMPORTANT: Never expose internal error messages in production
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: isClientError ? err.message : 'Internal server error',
|
||||
code: err.code || 'INTERNAL_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { errorHandler };
|
||||
@@ -0,0 +1,113 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class AiInsight {
|
||||
/**
|
||||
* Get AI insight by cycle and kandang
|
||||
* @param {string} cycleId - Cycle ID
|
||||
* @param {string|null} kandangId - Kandang ID (null for all kandangs)
|
||||
* @returns {Promise<Object|null>} Insight object or null if not found or expired
|
||||
*/
|
||||
static async getByCycleAndKandang(cycleId, kandangId = null) {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT * FROM ai_insights
|
||||
WHERE cycle_id = $1
|
||||
AND ($2::text IS NULL AND kandang_id IS NULL OR kandang_id = $2)
|
||||
AND expires_at > NOW()
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1`,
|
||||
[cycleId, kandangId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
|
||||
return this.formatInsight(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error getting AI insight:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update AI insight
|
||||
* @param {Object} insightData - Insight data
|
||||
* @returns {Promise<Object>} Created/updated insight
|
||||
*/
|
||||
static async upsert(insightData) {
|
||||
try {
|
||||
const { cycleId, kandangId, insightText, version = 'v1' } = insightData;
|
||||
|
||||
// Calculate expiry date (6 hours from now)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 6);
|
||||
|
||||
// Check if insight already exists
|
||||
const existing = await this.getByCycleAndKandang(cycleId, kandangId);
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
const result = await query(
|
||||
`UPDATE ai_insights
|
||||
SET insight_text = $1, version = $2, generated_at = NOW(), expires_at = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING *`,
|
||||
[insightText, version, expiresAt, existing.id]
|
||||
);
|
||||
|
||||
return this.formatInsight(result.rows[0]);
|
||||
} else {
|
||||
// Create new
|
||||
const result = await query(
|
||||
`INSERT INTO ai_insights (cycle_id, kandang_id, insight_text, version, generated_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), $5)
|
||||
RETURNING *`,
|
||||
[cycleId, kandangId, insightText, version, expiresAt]
|
||||
);
|
||||
|
||||
return this.formatInsight(result.rows[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error upserting AI insight:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired insights
|
||||
* @returns {Promise<number>} Number of deleted rows
|
||||
*/
|
||||
static async cleanupExpired() {
|
||||
try {
|
||||
const result = await query(
|
||||
'DELETE FROM ai_insights WHERE expires_at < NOW()',
|
||||
[]
|
||||
);
|
||||
|
||||
return result.rowCount;
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up expired insights:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format database row to API response format
|
||||
* @param {Object} row - Database row
|
||||
* @returns {Object} Formatted insight
|
||||
*/
|
||||
static formatInsight(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
cycleId: row.cycle_id,
|
||||
kandangId: row.kandang_id,
|
||||
insightText: row.insight_text,
|
||||
version: row.version,
|
||||
generatedAt: row.generated_at,
|
||||
expiresAt: row.expires_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AiInsight;
|
||||
@@ -0,0 +1,139 @@
|
||||
const { query } = require('../database/db');
|
||||
const { hashApiKey, generateApiKey } = require('../middleware/auth');
|
||||
|
||||
class ApiKey {
|
||||
/**
|
||||
* Create a new API key
|
||||
* @param {object} keyData - Key data
|
||||
* @returns {object} - Created key with plain key (shown once)
|
||||
*/
|
||||
static async create(keyData) {
|
||||
try {
|
||||
const { name, description, createdBy, expiresAt, rateLimit, allowedIps } = keyData;
|
||||
const key = generateApiKey();
|
||||
const keyHash = hashApiKey(key);
|
||||
const id = 'apk_' + Date.now();
|
||||
|
||||
await query(`
|
||||
INSERT INTO api_keys (
|
||||
id, key_hash, name, description, created_by,
|
||||
expires_at, rate_limit, allowed_ips
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, [
|
||||
id,
|
||||
keyHash,
|
||||
name,
|
||||
description || null,
|
||||
createdBy || null,
|
||||
expiresAt || null,
|
||||
rateLimit || 1000,
|
||||
allowedIps || null
|
||||
]);
|
||||
|
||||
// Return the plain key ONCE (never stored)
|
||||
return {
|
||||
id,
|
||||
key, // Plain key - show only once
|
||||
name,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys (without plain keys)
|
||||
* @returns {array} - All API keys
|
||||
*/
|
||||
static async getAll() {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT id, name, description, created_by, is_active,
|
||||
created_at, last_used_at, expires_at, rate_limit
|
||||
FROM api_keys
|
||||
ORDER BY created_at DESC
|
||||
`, []);
|
||||
return result.rows.map(this.formatRecord);
|
||||
} catch (error) {
|
||||
console.error('Error getting all API keys:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key by ID
|
||||
* @param {string} id - Key ID
|
||||
* @returns {object|null} - API key or null
|
||||
*/
|
||||
static async getById(id) {
|
||||
try {
|
||||
const result = await query(`
|
||||
SELECT id, name, description, created_by, is_active,
|
||||
created_at, last_used_at, expires_at, rate_limit, allowed_ips
|
||||
FROM api_keys
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatRecord(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error getting API key by ID:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API key status (activate/deactivate)
|
||||
* @param {string} id - Key ID
|
||||
* @param {boolean} isActive - Active status
|
||||
* @returns {object|null} - Updated key or null
|
||||
*/
|
||||
static async updateStatus(id, isActive) {
|
||||
try {
|
||||
const result = await query('UPDATE api_keys SET is_active = $1 WHERE id = $2', [isActive ? 1 : 0, id]);
|
||||
if (result.rowCount === 0) return null;
|
||||
return this.getById(id);
|
||||
} catch (error) {
|
||||
console.error('Error updating API key status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key
|
||||
* @param {string} id - Key ID
|
||||
* @returns {boolean} - True if deleted
|
||||
*/
|
||||
static async delete(id) {
|
||||
try {
|
||||
const result = await query('DELETE FROM api_keys WHERE id = $1', [id]);
|
||||
return result.rowCount > 0;
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format database record to camelCase
|
||||
* @param {object} record - Database record
|
||||
* @returns {object} - Formatted record
|
||||
*/
|
||||
static formatRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
createdBy: record.created_by,
|
||||
isActive: Boolean(record.is_active),
|
||||
createdAt: record.created_at,
|
||||
lastUsedAt: record.last_used_at,
|
||||
expiresAt: record.expires_at,
|
||||
rateLimit: record.rate_limit,
|
||||
allowedIps: record.allowed_ips
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiKey;
|
||||
@@ -0,0 +1,127 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class AuditLog {
|
||||
/**
|
||||
* Create a new audit log entry
|
||||
* @param {string} tableName - Name of the table that was modified
|
||||
* @param {string} recordId - ID of the record that was modified
|
||||
* @param {string} action - Type of action: CREATE, UPDATE, DELETE
|
||||
* @param {Object} oldValues - Previous values (null for CREATE)
|
||||
* @param {Object} newValues - New values (null for DELETE)
|
||||
* @param {string[]} changedFields - Array of field names that changed
|
||||
* @returns {Promise<Object>} Created audit log entry
|
||||
*/
|
||||
static async create(tableName, recordId, action, oldValues, newValues, changedFields) {
|
||||
const result = await query(
|
||||
`INSERT INTO audit_logs (table_name, record_id, action, old_values, new_values, changed_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
tableName,
|
||||
recordId.toString(),
|
||||
action,
|
||||
oldValues ? JSON.stringify(oldValues) : null,
|
||||
newValues ? JSON.stringify(newValues) : null,
|
||||
changedFields || []
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific table
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number} limit - Maximum number of logs to return (default: 50)
|
||||
* @param {number} offset - Number of logs to skip (for pagination)
|
||||
* @returns {Promise<Array>} Array of audit log entries
|
||||
*/
|
||||
static async getByTable(tableName, limit = 50, offset = 0) {
|
||||
const result = await query(
|
||||
`SELECT * FROM audit_logs
|
||||
WHERE table_name = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[tableName, limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific record
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {string} recordId - ID of the record
|
||||
* @returns {Promise<Array>} Array of audit log entries for this record
|
||||
*/
|
||||
static async getByRecord(tableName, recordId) {
|
||||
const result = await query(
|
||||
`SELECT * FROM audit_logs
|
||||
WHERE table_name = $1 AND record_id = $2
|
||||
ORDER BY created_at DESC`,
|
||||
[tableName, recordId.toString()]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent audit logs across all tables
|
||||
* @param {number} limit - Maximum number of logs to return
|
||||
* @returns {Promise<Array>} Array of recent audit log entries
|
||||
*/
|
||||
static async getRecent(limit = 50) {
|
||||
const result = await query(
|
||||
`SELECT * FROM audit_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs by date range
|
||||
* @param {string} tableName - Name of the table (optional)
|
||||
* @param {string} startDate - Start date (YYYY-MM-DD)
|
||||
* @param {string} endDate - End date (YYYY-MM-DD)
|
||||
* @param {number} limit - Maximum number of logs to return
|
||||
* @returns {Promise<Array>} Array of audit log entries
|
||||
*/
|
||||
static async getByDateRange(tableName, startDate, endDate, limit = 50) {
|
||||
let queryStr;
|
||||
let params;
|
||||
|
||||
if (tableName) {
|
||||
queryStr = `SELECT * FROM audit_logs
|
||||
WHERE table_name = $1
|
||||
AND created_at >= $2
|
||||
AND created_at <= $3
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $4`;
|
||||
params = [tableName, startDate, endDate, limit];
|
||||
} else {
|
||||
queryStr = `SELECT * FROM audit_logs
|
||||
WHERE created_at >= $1
|
||||
AND created_at <= $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3`;
|
||||
params = [startDate, endDate, limit];
|
||||
}
|
||||
|
||||
const result = await query(queryStr, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of audit logs for a table
|
||||
* @param {string} tableName - Name of the table
|
||||
* @returns {Promise<number>} Count of audit logs
|
||||
*/
|
||||
static async getCount(tableName) {
|
||||
const result = await query(
|
||||
`SELECT COUNT(*) as count FROM audit_logs WHERE table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
return parseInt(result.rows[0].count, 10);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuditLog;
|
||||
@@ -0,0 +1,132 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class Camera {
|
||||
/**
|
||||
* Get all cameras
|
||||
* @returns {array} - All cameras
|
||||
*/
|
||||
static async getAll() {
|
||||
try {
|
||||
const result = await query('SELECT * FROM cameras ORDER BY name', []);
|
||||
return result.rows.map(this.formatRecord);
|
||||
} catch (error) {
|
||||
console.error('Error getting all cameras:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get camera by ID
|
||||
* @param {string} id - Camera ID
|
||||
* @returns {object|null} - Camera or null
|
||||
*/
|
||||
static async getById(id) {
|
||||
try {
|
||||
const result = await query('SELECT * FROM cameras WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatRecord(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error getting camera by ID:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cameras by floor
|
||||
* @param {string} floorId - Floor ID
|
||||
* @returns {array} - Cameras on floor
|
||||
*/
|
||||
static async getByFloor(floorId) {
|
||||
try {
|
||||
const result = await query('SELECT * FROM cameras WHERE floor_id = $1', [floorId]);
|
||||
return result.rows.map(this.formatRecord);
|
||||
} catch (error) {
|
||||
console.error('Error getting cameras by floor:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new camera
|
||||
* @param {object} cameraData - Camera data
|
||||
* @returns {object} - Created camera
|
||||
*/
|
||||
static async create(cameraData) {
|
||||
try {
|
||||
const { id, name, cameraType, rtspUrl, floorId, model, status } = cameraData;
|
||||
await query(`
|
||||
INSERT INTO cameras (id, name, camera_type, rtsp_url, floor_id, model, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`, [
|
||||
id,
|
||||
name,
|
||||
cameraType,
|
||||
rtspUrl || null,
|
||||
floorId || null,
|
||||
model || null,
|
||||
status || 'offline'
|
||||
]);
|
||||
return this.getById(id);
|
||||
} catch (error) {
|
||||
console.error('Error creating camera:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update camera status
|
||||
* @param {string} id - Camera ID
|
||||
* @param {string} status - New status (online, offline, error)
|
||||
* @returns {object|null} - Updated camera or null
|
||||
*/
|
||||
static async updateStatus(id, status) {
|
||||
try {
|
||||
const result = await query(`
|
||||
UPDATE cameras
|
||||
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
`, [status, id]);
|
||||
if (result.rowCount === 0) return null;
|
||||
return this.getById(id);
|
||||
} catch (error) {
|
||||
console.error('Error updating camera status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a camera
|
||||
* @param {string} id - Camera ID
|
||||
* @returns {boolean} - True if deleted
|
||||
*/
|
||||
static async delete(id) {
|
||||
try {
|
||||
const result = await query('DELETE FROM cameras WHERE id = $1', [id]);
|
||||
return result.rowCount > 0;
|
||||
} catch (error) {
|
||||
console.error('Error deleting camera:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format database record to camelCase
|
||||
* @param {object} record - Database record
|
||||
* @returns {object} - Formatted record
|
||||
*/
|
||||
static formatRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
cameraType: record.camera_type,
|
||||
rtspUrl: record.rtsp_url,
|
||||
floorId: record.floor_id,
|
||||
model: record.model,
|
||||
status: record.status,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Camera;
|
||||
@@ -0,0 +1,99 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class ChickenCount {
|
||||
static async upsert(date, kandangId, filename, totalCount) {
|
||||
const result = await query(`
|
||||
INSERT INTO chicken_counting (date, kandang_id, filename, total_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (date, kandang_id)
|
||||
DO UPDATE SET
|
||||
filename = EXCLUDED.filename,
|
||||
total_count = EXCLUDED.total_count,
|
||||
updated_at = NOW()
|
||||
RETURNING *
|
||||
`, [date, kandangId, filename, totalCount]);
|
||||
|
||||
// Fetch with kandang name
|
||||
const record = await query(`
|
||||
SELECT cc.*, k.name as kandang_name
|
||||
FROM chicken_counting cc
|
||||
JOIN kandangs k ON cc.kandang_id = k.id
|
||||
WHERE cc.id = $1
|
||||
`, [result.rows[0].id]);
|
||||
|
||||
return this.formatRecord(record.rows[0]);
|
||||
}
|
||||
|
||||
static async insertIfNotExists(date, kandangId, filename, totalCount) {
|
||||
// Check if record already exists
|
||||
const existing = await query(`
|
||||
SELECT cc.*, k.name as kandang_name
|
||||
FROM chicken_counting cc
|
||||
JOIN kandangs k ON cc.kandang_id = k.id
|
||||
WHERE cc.date = $1 AND cc.kandang_id = $2
|
||||
`, [date, kandangId]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Record exists, return existing data without updating
|
||||
return {
|
||||
...this.formatRecord(existing.rows[0]),
|
||||
skipped: true
|
||||
};
|
||||
}
|
||||
|
||||
// Record doesn't exist, insert new
|
||||
const result = await query(`
|
||||
INSERT INTO chicken_counting (date, kandang_id, filename, total_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`, [date, kandangId, filename, totalCount]);
|
||||
|
||||
// Fetch with kandang name
|
||||
const record = await query(`
|
||||
SELECT cc.*, k.name as kandang_name
|
||||
FROM chicken_counting cc
|
||||
JOIN kandangs k ON cc.kandang_id = k.id
|
||||
WHERE cc.id = $1
|
||||
`, [result.rows[0].id]);
|
||||
|
||||
return {
|
||||
...this.formatRecord(record.rows[0]),
|
||||
skipped: false
|
||||
};
|
||||
}
|
||||
|
||||
static async getByDateRange(startDate, endDate, kandangId) {
|
||||
let sql = `
|
||||
SELECT cc.*, k.name as kandang_name
|
||||
FROM chicken_counting cc
|
||||
JOIN kandangs k ON cc.kandang_id = k.id
|
||||
WHERE cc.date >= $1 AND cc.date <= $2
|
||||
`;
|
||||
const params = [startDate, endDate];
|
||||
|
||||
if (kandangId) {
|
||||
sql += ` AND cc.kandang_id = $3`;
|
||||
params.push(kandangId);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY cc.date ASC`;
|
||||
|
||||
const result = await query(sql, params);
|
||||
return result.rows.map(row => this.formatRecord(row));
|
||||
}
|
||||
|
||||
static formatRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
date: record.date,
|
||||
kandangId: record.kandang_id,
|
||||
kandangName: record.kandang_name,
|
||||
filename: record.filename,
|
||||
totalCount: record.total_count,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChickenCount;
|
||||
@@ -0,0 +1,117 @@
|
||||
const { query, toISODate, parseISODate } = require('../database/db');
|
||||
|
||||
class Cycle {
|
||||
static async getAll() {
|
||||
try {
|
||||
const result = await query('SELECT * FROM cycles ORDER BY start_date DESC', []);
|
||||
return result.rows.map(this.formatCycle);
|
||||
} catch (error) {
|
||||
console.error('Error getting all cycles:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getById(id) {
|
||||
try {
|
||||
const result = await query('SELECT * FROM cycles WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatCycle(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error getting cycle by ID:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async create(cycleData) {
|
||||
try {
|
||||
const { id, totalDays, currentDay, startDate, endDate, chickInWeight, docInCount, status } = cycleData;
|
||||
await query('INSERT INTO cycles (id, total_days, current_day, start_date, end_date, chick_in_weight, doc_in_count, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)', [id, totalDays, currentDay, toISODate(startDate), toISODate(endDate), chickInWeight || null, docInCount || null, status]);
|
||||
return this.getById(id);
|
||||
} catch (error) {
|
||||
console.error('Error creating cycle:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async update(id, cycleData) {
|
||||
try {
|
||||
const { totalDays, currentDay, startDate, endDate, chickInWeight, docInCount, status } = cycleData;
|
||||
const result = await query('UPDATE cycles SET total_days = $1, current_day = $2, start_date = $3, end_date = $4, chick_in_weight = $5, doc_in_count = $6, status = $7, updated_at = CURRENT_TIMESTAMP WHERE id = $8', [totalDays, currentDay, toISODate(startDate), toISODate(endDate), chickInWeight || null, docInCount || null, status, id]);
|
||||
if (result.rowCount === 0) return null;
|
||||
return this.getById(id);
|
||||
} catch (error) {
|
||||
console.error('Error updating cycle:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async delete(id) {
|
||||
try {
|
||||
const result = await query('DELETE FROM cycles WHERE id = $1', [id]);
|
||||
return result.rowCount > 0;
|
||||
} catch (error) {
|
||||
console.error('Error deleting cycle:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async getActive() {
|
||||
try {
|
||||
const result = await query("SELECT * FROM cycles WHERE status = 'Active' LIMIT 1", []);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatCycle(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error getting active cycle:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async markAllActiveAsCompleted() {
|
||||
try {
|
||||
const result = await query(
|
||||
"UPDATE cycles SET status = 'Completed', updated_at = CURRENT_TIMESTAMP WHERE status = 'Active'",
|
||||
[]
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error marking active cycles as completed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static formatCycle(cycle) {
|
||||
// Calculate current day dynamically based on start_date
|
||||
let calculatedCurrentDay = cycle.current_day;
|
||||
|
||||
if (cycle.status === 'Active' && cycle.start_date) {
|
||||
const startDate = new Date(cycle.start_date);
|
||||
const today = new Date();
|
||||
|
||||
// Reset time to midnight for accurate day calculation
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Calculate days difference
|
||||
const diffMs = today - startDate;
|
||||
const daysDiff = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Use calculated day, but don't exceed totalDays
|
||||
calculatedCurrentDay = Math.min(daysDiff, cycle.total_days);
|
||||
}
|
||||
|
||||
return {
|
||||
id: cycle.id,
|
||||
totalDays: cycle.total_days,
|
||||
currentDay: calculatedCurrentDay, // Use dynamically calculated day for active cycles
|
||||
startDate: cycle.start_date,
|
||||
endDate: cycle.end_date,
|
||||
chickInWeight: cycle.chick_in_weight,
|
||||
docInCount: cycle.doc_in_count,
|
||||
status: cycle.status,
|
||||
createdAt: cycle.created_at,
|
||||
updatedAt: cycle.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Cycle;
|
||||
@@ -0,0 +1,51 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class CycleFeedInitialBalance {
|
||||
/**
|
||||
* Get initial balance for a specific cycle
|
||||
* @param {string} cycleId - Cycle ID
|
||||
* @returns {Promise<Object|null>} Initial balance record or null if not found
|
||||
*/
|
||||
static async getByCycleId(cycleId) {
|
||||
const result = await query(
|
||||
'SELECT * FROM cycle_feed_initial_balance WHERE cycle_id = $1',
|
||||
[cycleId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update initial balance for a cycle (upsert)
|
||||
* @param {string} cycleId - Cycle ID
|
||||
* @param {number} saldoAwal - Initial balance count (must be non-negative)
|
||||
* @param {string} createdBy - Username of creator
|
||||
* @returns {Promise<Object>} Created/updated initial balance record
|
||||
*/
|
||||
static async upsert(cycleId, saldoAwal, createdBy) {
|
||||
const result = await query(
|
||||
`INSERT INTO cycle_feed_initial_balance (cycle_id, saldo_awal, created_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (cycle_id)
|
||||
DO UPDATE SET
|
||||
saldo_awal = $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *`,
|
||||
[cycleId, saldoAwal, createdBy]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete initial balance for a cycle
|
||||
* @param {string} cycleId - Cycle ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async delete(cycleId) {
|
||||
await query(
|
||||
'DELETE FROM cycle_feed_initial_balance WHERE cycle_id = $1',
|
||||
[cycleId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CycleFeedInitialBalance;
|
||||
@@ -0,0 +1,31 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class FeedSackColumnConfig {
|
||||
static async get() {
|
||||
const result = await query('SELECT config FROM feed_sack_column_config ORDER BY id LIMIT 1');
|
||||
if (result.rows.length === 0) return null;
|
||||
return result.rows[0].config;
|
||||
}
|
||||
|
||||
static async save(config) {
|
||||
// Check if a row exists
|
||||
const existing = await query('SELECT id FROM feed_sack_column_config LIMIT 1');
|
||||
if (existing.rows.length === 0) {
|
||||
// Insert
|
||||
const result = await query(
|
||||
'INSERT INTO feed_sack_column_config (config) VALUES ($1) RETURNING config',
|
||||
[JSON.stringify(config)]
|
||||
);
|
||||
return result.rows[0].config;
|
||||
} else {
|
||||
// Update the existing row
|
||||
const result = await query(
|
||||
'UPDATE feed_sack_column_config SET config = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING config',
|
||||
[JSON.stringify(config), existing.rows[0].id]
|
||||
);
|
||||
return result.rows[0].config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FeedSackColumnConfig;
|
||||
@@ -0,0 +1,103 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class Kandang {
|
||||
static async getAll() {
|
||||
const result = await query('SELECT * FROM kandangs ORDER BY id');
|
||||
return result.rows.map(this.formatKandang);
|
||||
}
|
||||
|
||||
static async getById(id) {
|
||||
const result = await query('SELECT * FROM kandangs WHERE id = $1', [id]);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatKandang(result.rows[0]);
|
||||
}
|
||||
|
||||
static async create(name) {
|
||||
const result = await query(
|
||||
'INSERT INTO kandangs (name) VALUES ($1) RETURNING *',
|
||||
[name]
|
||||
);
|
||||
return this.formatKandang(result.rows[0]);
|
||||
}
|
||||
|
||||
static async update(id, name) {
|
||||
const result = await query(
|
||||
'UPDATE kandangs SET name = $1 WHERE id = $2 RETURNING *',
|
||||
[name, id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatKandang(result.rows[0]);
|
||||
}
|
||||
|
||||
static async delete(id) {
|
||||
const result = await query('DELETE FROM kandangs WHERE id = $1', [id]);
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
// Get kandangs with their doc_in_count for a specific cycle
|
||||
static async getByCycle(cycleId) {
|
||||
const result = await query(`
|
||||
SELECT k.*, kc.doc_in_count
|
||||
FROM kandangs k
|
||||
LEFT JOIN kandang_cycles kc ON k.id = kc.kandang_id AND kc.cycle_id = $1
|
||||
ORDER BY k.id
|
||||
`, [cycleId]);
|
||||
return result.rows.map(row => ({
|
||||
...this.formatKandang(row),
|
||||
docInCount: parseInt(row.doc_in_count) || 0
|
||||
}));
|
||||
}
|
||||
|
||||
// Set doc_in_count for a kandang in a specific cycle (upsert)
|
||||
static async setDocInCount(kandangId, cycleId, docInCount) {
|
||||
await query(`
|
||||
INSERT INTO kandang_cycles (kandang_id, cycle_id, doc_in_count)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (kandang_id, cycle_id)
|
||||
DO UPDATE SET doc_in_count = EXCLUDED.doc_in_count
|
||||
`, [kandangId, cycleId, docInCount]);
|
||||
}
|
||||
|
||||
// Copy DOC counts from one cycle to another (useful when starting new cycle)
|
||||
static async copyDocCountsToCycle(fromCycleId, toCycleId) {
|
||||
await query(`
|
||||
INSERT INTO kandang_cycles (kandang_id, cycle_id, doc_in_count)
|
||||
SELECT kandang_id, $2, doc_in_count
|
||||
FROM kandang_cycles
|
||||
WHERE cycle_id = $1
|
||||
ON CONFLICT (kandang_id, cycle_id) DO NOTHING
|
||||
`, [fromCycleId, toCycleId]);
|
||||
}
|
||||
|
||||
// Get kandang by cage UUID
|
||||
static async getByCageUuid(cageUuid) {
|
||||
const result = await query(
|
||||
'SELECT * FROM kandangs WHERE cage_uuid = $1',
|
||||
[cageUuid]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatKandang(result.rows[0]);
|
||||
}
|
||||
|
||||
// Update cage UUID for a kandang
|
||||
static async updateCageUuid(id, cageUuid) {
|
||||
const result = await query(
|
||||
'UPDATE kandangs SET cage_uuid = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
|
||||
[cageUuid, id]
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatKandang(result.rows[0]);
|
||||
}
|
||||
|
||||
static formatKandang(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
cageUuid: record.cage_uuid || null,
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Kandang;
|
||||
@@ -0,0 +1,70 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class ManualFeedSack {
|
||||
/**
|
||||
* Get all manual entries for a date range
|
||||
* @param {string} startDate - Start date (YYYY-MM-DD)
|
||||
* @param {string} endDate - End date (YYYY-MM-DD)
|
||||
* @returns {Promise<Array>} Array of manual feed sack entries
|
||||
*/
|
||||
static async getByDateRange(startDate, endDate) {
|
||||
const result = await query(
|
||||
'SELECT * FROM manual_feed_sack_entries WHERE date >= $1 AND date <= $2 ORDER BY date ASC',
|
||||
[startDate, endDate]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manual entry for specific date and camera
|
||||
* @param {string} date - Date (YYYY-MM-DD)
|
||||
* @param {string} cameraName - Camera name
|
||||
* @returns {Promise<Object|null>} Manual entry or null if not found
|
||||
*/
|
||||
static async getByDateAndCamera(date, cameraName) {
|
||||
const result = await query(
|
||||
'SELECT * FROM manual_feed_sack_entries WHERE date = $1 AND camera_name = $2',
|
||||
[date, cameraName]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update manual entry (upsert)
|
||||
* @param {string} date - Date (YYYY-MM-DD)
|
||||
* @param {string} cameraName - Camera name
|
||||
* @param {number} count - Sack count
|
||||
* @param {string} createdBy - Username of creator
|
||||
* @param {string} keterangan - Optional notes/remarks (max 200 chars)
|
||||
* @returns {Promise<Object>} Created/updated entry
|
||||
*/
|
||||
static async upsert(date, cameraName, count, createdBy, keterangan = null) {
|
||||
const result = await query(
|
||||
`INSERT INTO manual_feed_sack_entries (date, camera_name, count, created_by, keterangan)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (date, camera_name)
|
||||
DO UPDATE SET
|
||||
count = $3,
|
||||
keterangan = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *`,
|
||||
[date, cameraName, count, createdBy, keterangan]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete manual entry
|
||||
* @param {string} date - Date (YYYY-MM-DD)
|
||||
* @param {string} cameraName - Camera name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async delete(date, cameraName) {
|
||||
await query(
|
||||
'DELETE FROM manual_feed_sack_entries WHERE date = $1 AND camera_name = $2',
|
||||
[date, cameraName]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ManualFeedSack;
|
||||
@@ -0,0 +1,143 @@
|
||||
const { query } = require('../database/db');
|
||||
|
||||
class Mortality {
|
||||
static async getByCycle(cycleId, kandangId = null) {
|
||||
let sql = 'SELECT * FROM mortality_records WHERE cycle_id = $1';
|
||||
const params = [cycleId];
|
||||
|
||||
if (kandangId !== null && kandangId !== undefined) {
|
||||
sql += ' AND kandang_id = $2';
|
||||
params.push(kandangId);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY day';
|
||||
const result = await query(sql, params);
|
||||
return result.rows.map(this.formatRecord);
|
||||
}
|
||||
|
||||
static async getByDay(cycleId, day, kandangId = null) {
|
||||
let sql = 'SELECT * FROM mortality_records WHERE cycle_id = $1 AND day = $2';
|
||||
const params = [cycleId, day];
|
||||
|
||||
if (kandangId !== null && kandangId !== undefined) {
|
||||
sql += ' AND kandang_id = $3';
|
||||
params.push(kandangId);
|
||||
} else {
|
||||
sql += ' AND kandang_id IS NULL';
|
||||
}
|
||||
|
||||
const result = await query(sql, params);
|
||||
if (result.rows.length === 0) return null;
|
||||
return this.formatRecord(result.rows[0]);
|
||||
}
|
||||
|
||||
static async upsert(
|
||||
cycleId,
|
||||
day,
|
||||
mortalityCount,
|
||||
isEdited = true,
|
||||
chickenCount = null,
|
||||
kandangId = null,
|
||||
panen = 0,
|
||||
keterangan = null,
|
||||
afkir = 0,
|
||||
beratPanen = 0
|
||||
) {
|
||||
try {
|
||||
const sql = `
|
||||
INSERT INTO mortality_records (cycle_id, day, mortality_count, chicken_count, kandang_id, is_edited, afkir, panen, berat_panen, keterangan, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (cycle_id, day, COALESCE(kandang_id, -1))
|
||||
DO UPDATE SET mortality_count = EXCLUDED.mortality_count,
|
||||
chicken_count = EXCLUDED.chicken_count,
|
||||
is_edited = EXCLUDED.is_edited,
|
||||
afkir = EXCLUDED.afkir,
|
||||
panen = EXCLUDED.panen,
|
||||
berat_panen = EXCLUDED.berat_panen,
|
||||
keterangan = EXCLUDED.keterangan,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
await query(sql, [
|
||||
cycleId,
|
||||
day,
|
||||
mortalityCount,
|
||||
chickenCount,
|
||||
kandangId,
|
||||
isEdited ? 1 : 0,
|
||||
afkir,
|
||||
panen,
|
||||
beratPanen,
|
||||
keterangan
|
||||
]);
|
||||
return this.getByDay(cycleId, day, kandangId);
|
||||
} catch (error) {
|
||||
console.error('Error upserting mortality record:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async delete(cycleId, day, kandangId = null) {
|
||||
try {
|
||||
let sql = 'DELETE FROM mortality_records WHERE cycle_id = $1 AND day = $2';
|
||||
const params = [cycleId, day];
|
||||
|
||||
if (kandangId !== null && kandangId !== undefined) {
|
||||
sql += ' AND kandang_id = $3';
|
||||
params.push(kandangId);
|
||||
} else {
|
||||
sql += ' AND kandang_id IS NULL';
|
||||
}
|
||||
|
||||
const result = await query(sql, params);
|
||||
return result.rowCount > 0;
|
||||
} catch (error) {
|
||||
console.error('Error deleting mortality record:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get totals aggregated across all kandangs for a cycle
|
||||
static async getTotalsByCycle(cycleId) {
|
||||
const result = await query(`
|
||||
SELECT day,
|
||||
SUM(mortality_count) as total_mortality,
|
||||
SUM(afkir) as total_afkir,
|
||||
SUM(chicken_count) as total_chicken_count,
|
||||
SUM(panen) as total_panen,
|
||||
SUM(berat_panen) as total_berat_panen
|
||||
FROM mortality_records
|
||||
WHERE cycle_id = $1
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
`, [cycleId]);
|
||||
return result.rows.map(row => ({
|
||||
day: row.day,
|
||||
mortalityCount: parseInt(row.total_mortality) || 0,
|
||||
afkir: parseInt(row.total_afkir) || 0,
|
||||
chickenCount: row.total_chicken_count ? parseInt(row.total_chicken_count) : null,
|
||||
panen: parseInt(row.total_panen) || 0,
|
||||
beratPanen: row.total_berat_panen ? parseFloat(row.total_berat_panen) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
static formatRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
cycleId: record.cycle_id,
|
||||
day: record.day,
|
||||
mortalityCount: record.mortality_count,
|
||||
chickenCount: record.chicken_count,
|
||||
population: record.population,
|
||||
kandangId: record.kandang_id,
|
||||
isEdited: Boolean(record.is_edited),
|
||||
afkir: record.afkir || 0,
|
||||
panen: record.panen || 0,
|
||||
beratPanen: record.berat_panen ? parseFloat(record.berat_panen) : 0,
|
||||
keterangan: record.keterangan || '',
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Mortality;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Cycle from '../Cycle.js';
|
||||
import { query } from '../../database/db.js';
|
||||
|
||||
describe('Cycle Model', () => {
|
||||
// Clean up test data before and after each test
|
||||
beforeEach(async () => {
|
||||
await query('DELETE FROM cycles WHERE id LIKE $1', ['TEST-%']);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await query('DELETE FROM cycles WHERE id LIKE $1', ['TEST-%']);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new cycle with all fields', async () => {
|
||||
const cycleData = {
|
||||
id: 'TEST-CYCLE-001',
|
||||
totalDays: 35,
|
||||
currentDay: 1,
|
||||
startDate: '2026-04-01',
|
||||
chickInWeight: 40,
|
||||
docInCount: 10000,
|
||||
status: 'Active',
|
||||
};
|
||||
|
||||
const cycle = await Cycle.create(cycleData);
|
||||
|
||||
expect(cycle).toBeDefined();
|
||||
expect(cycle.id).toBe('TEST-CYCLE-001');
|
||||
expect(cycle.totalDays).toBe(35);
|
||||
expect(cycle.currentDay).toBeGreaterThanOrEqual(1);
|
||||
expect(cycle.docInCount).toBe(10000);
|
||||
expect(cycle.status).toBe('Active');
|
||||
});
|
||||
|
||||
it('should create a cycle with optional fields as null', async () => {
|
||||
const cycleData = {
|
||||
id: 'TEST-CYCLE-002',
|
||||
totalDays: 35,
|
||||
currentDay: 0,
|
||||
startDate: '2026-05-01',
|
||||
status: 'Upcoming',
|
||||
};
|
||||
|
||||
const cycle = await Cycle.create(cycleData);
|
||||
|
||||
expect(cycle).toBeDefined();
|
||||
expect(cycle.id).toBe('TEST-CYCLE-002');
|
||||
expect(cycle.chickInWeight).toBeNull();
|
||||
expect(cycle.docInCount).toBeNull();
|
||||
expect(cycle.endDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should retrieve a cycle by ID', async () => {
|
||||
// Create test cycle
|
||||
await Cycle.create({
|
||||
id: 'TEST-CYCLE-003',
|
||||
totalDays: 35,
|
||||
currentDay: 5,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
const cycle = await Cycle.getById('TEST-CYCLE-003');
|
||||
|
||||
expect(cycle).toBeDefined();
|
||||
expect(cycle.id).toBe('TEST-CYCLE-003');
|
||||
expect(cycle.totalDays).toBe(35);
|
||||
});
|
||||
|
||||
it('should return null for non-existent cycle', async () => {
|
||||
const cycle = await Cycle.getById('DOES-NOT-EXIST');
|
||||
expect(cycle).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should retrieve all cycles ordered by start date', async () => {
|
||||
// Create multiple test cycles
|
||||
await Cycle.create({
|
||||
id: 'TEST-CYCLE-004',
|
||||
totalDays: 35,
|
||||
currentDay: 1,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
await Cycle.create({
|
||||
id: 'TEST-CYCLE-005',
|
||||
totalDays: 35,
|
||||
currentDay: 0,
|
||||
startDate: '2026-05-01',
|
||||
status: 'Upcoming',
|
||||
});
|
||||
|
||||
const cycles = await Cycle.getAll();
|
||||
|
||||
const testCycles = cycles.filter((c) => c.id.startsWith('TEST-'));
|
||||
expect(testCycles.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify ordering (most recent start date first)
|
||||
const testCycle005 = testCycles.find((c) => c.id === 'TEST-CYCLE-005');
|
||||
const testCycle004 = testCycles.find((c) => c.id === 'TEST-CYCLE-004');
|
||||
expect(testCycles.indexOf(testCycle005)).toBeLessThan(testCycles.indexOf(testCycle004));
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing cycle', async () => {
|
||||
// Create initial cycle
|
||||
await Cycle.create({
|
||||
id: 'TEST-CYCLE-006',
|
||||
totalDays: 35,
|
||||
currentDay: 5,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
docInCount: 10000,
|
||||
});
|
||||
|
||||
// Update the cycle
|
||||
const updated = await Cycle.update('TEST-CYCLE-006', {
|
||||
totalDays: 40,
|
||||
currentDay: 10,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
docInCount: 9500,
|
||||
});
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated.totalDays).toBe(40);
|
||||
expect(updated.docInCount).toBe(9500);
|
||||
});
|
||||
|
||||
it('should return null when updating non-existent cycle', async () => {
|
||||
const result = await Cycle.update('DOES-NOT-EXIST', {
|
||||
totalDays: 35,
|
||||
currentDay: 1,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete an existing cycle', async () => {
|
||||
// Create cycle to delete
|
||||
await Cycle.create({
|
||||
id: 'TEST-CYCLE-007',
|
||||
totalDays: 35,
|
||||
currentDay: 1,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
const deleted = await Cycle.delete('TEST-CYCLE-007');
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const cycle = await Cycle.getById('TEST-CYCLE-007');
|
||||
expect(cycle).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false when deleting non-existent cycle', async () => {
|
||||
const deleted = await Cycle.delete('DOES-NOT-EXIST');
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActive', () => {
|
||||
it('should retrieve the active cycle', async () => {
|
||||
// Create an active cycle
|
||||
await Cycle.create({
|
||||
id: 'TEST-CYCLE-008',
|
||||
totalDays: 35,
|
||||
currentDay: 10,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
const activeCycle = await Cycle.getActive();
|
||||
|
||||
// Should return either our test cycle or another active cycle
|
||||
expect(activeCycle).toBeDefined();
|
||||
expect(activeCycle.status).toBe('Active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCycle', () => {
|
||||
it('should calculate current day dynamically for active cycles', () => {
|
||||
const today = new Date();
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 10); // 10 days ago
|
||||
|
||||
const rawCycle = {
|
||||
id: 'TEST-FORMAT',
|
||||
total_days: 35,
|
||||
current_day: 1,
|
||||
start_date: startDate.toISOString().split('T')[0],
|
||||
end_date: null,
|
||||
chick_in_weight: 40,
|
||||
doc_in_count: 10000,
|
||||
status: 'Active',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
const formatted = Cycle.formatCycle(rawCycle);
|
||||
|
||||
expect(formatted.currentDay).toBe(10);
|
||||
expect(formatted.id).toBe('TEST-FORMAT');
|
||||
expect(formatted.status).toBe('Active');
|
||||
});
|
||||
|
||||
it('should not calculate current day for completed cycles', () => {
|
||||
const rawCycle = {
|
||||
id: 'TEST-FORMAT-2',
|
||||
total_days: 35,
|
||||
current_day: 35,
|
||||
start_date: '2026-03-01',
|
||||
end_date: '2026-04-05',
|
||||
chick_in_weight: 40,
|
||||
doc_in_count: 10000,
|
||||
status: 'Completed',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
const formatted = Cycle.formatCycle(rawCycle);
|
||||
|
||||
expect(formatted.currentDay).toBe(35);
|
||||
expect(formatted.status).toBe('Completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Mortality from '../Mortality.js';
|
||||
import Cycle from '../Cycle.js';
|
||||
import { query } from '../../database/db.js';
|
||||
|
||||
describe('Mortality Model', () => {
|
||||
const TEST_CYCLE_ID = 'TEST-MORTALITY-CYCLE';
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await query('DELETE FROM mortality_records WHERE cycle_id = $1', [TEST_CYCLE_ID]);
|
||||
await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]);
|
||||
|
||||
// Create test cycle
|
||||
await Cycle.create({
|
||||
id: TEST_CYCLE_ID,
|
||||
totalDays: 35,
|
||||
currentDay: 1,
|
||||
startDate: '2026-04-01',
|
||||
status: 'Active',
|
||||
docInCount: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await query('DELETE FROM mortality_records WHERE cycle_id = $1', [TEST_CYCLE_ID]);
|
||||
await query('DELETE FROM cycles WHERE id = $1', [TEST_CYCLE_ID]);
|
||||
});
|
||||
|
||||
describe('upsert', () => {
|
||||
it('should insert a new mortality record', async () => {
|
||||
const record = await Mortality.upsert(TEST_CYCLE_ID, 1, 50, true, 9950);
|
||||
|
||||
expect(record).toBeDefined();
|
||||
expect(record.cycleId).toBe(TEST_CYCLE_ID);
|
||||
expect(record.day).toBe(1);
|
||||
expect(record.mortalityCount).toBe(50);
|
||||
expect(record.chickenCount).toBe(9950);
|
||||
expect(record.isEdited).toBe(true);
|
||||
});
|
||||
|
||||
it('should update an existing mortality record', async () => {
|
||||
// Insert initial record
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 50, true, 9950);
|
||||
|
||||
// Update the same record
|
||||
const updated = await Mortality.upsert(TEST_CYCLE_ID, 1, 55, true, 9945);
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated.mortalityCount).toBe(55);
|
||||
expect(updated.chickenCount).toBe(9945);
|
||||
});
|
||||
|
||||
it('should handle afkir and panen fields', async () => {
|
||||
const record = await Mortality.upsert(
|
||||
TEST_CYCLE_ID,
|
||||
10,
|
||||
20,
|
||||
true,
|
||||
9500,
|
||||
null,
|
||||
100,
|
||||
'Culled due to disease',
|
||||
30
|
||||
);
|
||||
|
||||
expect(record).toBeDefined();
|
||||
expect(record.afkir).toBe(30);
|
||||
expect(record.panen).toBe(100);
|
||||
expect(record.beratPanen).toBe(0);
|
||||
expect(record.keterangan).toBe('Culled due to disease');
|
||||
});
|
||||
|
||||
it('should persist harvest weight', async () => {
|
||||
const record = await Mortality.upsert(
|
||||
TEST_CYCLE_ID,
|
||||
12,
|
||||
15,
|
||||
true,
|
||||
9985,
|
||||
null,
|
||||
80,
|
||||
'Harvest day',
|
||||
5,
|
||||
123.45
|
||||
);
|
||||
|
||||
expect(record).toBeDefined();
|
||||
expect(record.beratPanen).toBe(123.45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByDay', () => {
|
||||
it('should retrieve a mortality record by cycle and day', async () => {
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 5, 45, true, 9500);
|
||||
|
||||
const record = await Mortality.getByDay(TEST_CYCLE_ID, 5);
|
||||
|
||||
expect(record).toBeDefined();
|
||||
expect(record.day).toBe(5);
|
||||
expect(record.mortalityCount).toBe(45);
|
||||
});
|
||||
|
||||
it('should return null for non-existent record', async () => {
|
||||
const record = await Mortality.getByDay(TEST_CYCLE_ID, 999);
|
||||
expect(record).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle kandang-specific records', async () => {
|
||||
// Create records for different kandangs
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2);
|
||||
|
||||
const recordKandang1 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 1);
|
||||
const recordKandang2 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 2);
|
||||
|
||||
expect(recordKandang1.mortalityCount).toBe(20);
|
||||
expect(recordKandang2.mortalityCount).toBe(30);
|
||||
expect(recordKandang1.kandangId).toBe(1);
|
||||
expect(recordKandang2.kandangId).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByCycle', () => {
|
||||
it('should retrieve all mortality records for a cycle', async () => {
|
||||
// Create multiple records
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 50, true, 9950);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 2, 45, true, 9905);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 3, 40, true, 9865);
|
||||
|
||||
const records = await Mortality.getByCycle(TEST_CYCLE_ID);
|
||||
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0].day).toBe(1);
|
||||
expect(records[1].day).toBe(2);
|
||||
expect(records[2].day).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by kandang when provided', async () => {
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 2, 25, true, 4955, 1);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2);
|
||||
|
||||
const recordsKandang1 = await Mortality.getByCycle(TEST_CYCLE_ID, 1);
|
||||
const recordsKandang2 = await Mortality.getByCycle(TEST_CYCLE_ID, 2);
|
||||
|
||||
expect(recordsKandang1).toHaveLength(2);
|
||||
expect(recordsKandang2).toHaveLength(1);
|
||||
expect(recordsKandang1.every((r) => r.kandangId === 1)).toBe(true);
|
||||
expect(recordsKandang2.every((r) => r.kandangId === 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalsByCycle', () => {
|
||||
it('should aggregate mati, afkir, and panen across kandangs', async () => {
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1, 5, 'Kandang 1', 3, 11.25);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2, 7, 'Kandang 2', 4, 8.75);
|
||||
|
||||
const totals = await Mortality.getTotalsByCycle(TEST_CYCLE_ID);
|
||||
|
||||
expect(totals).toHaveLength(1);
|
||||
expect(totals[0].day).toBe(1);
|
||||
expect(totals[0].mortalityCount).toBe(50);
|
||||
expect(totals[0].afkir).toBe(7);
|
||||
expect(totals[0].panen).toBe(12);
|
||||
expect(totals[0].beratPanen).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a mortality record', async () => {
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 10, 50, true, 9500);
|
||||
|
||||
const deleted = await Mortality.delete(TEST_CYCLE_ID, 10);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const record = await Mortality.getByDay(TEST_CYCLE_ID, 10);
|
||||
expect(record).toBeNull();
|
||||
});
|
||||
|
||||
it('should return false when deleting non-existent record', async () => {
|
||||
const deleted = await Mortality.delete(TEST_CYCLE_ID, 999);
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('should delete kandang-specific records', async () => {
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 20, true, 4980, 1);
|
||||
await Mortality.upsert(TEST_CYCLE_ID, 1, 30, true, 4970, 2);
|
||||
|
||||
const deleted = await Mortality.delete(TEST_CYCLE_ID, 1, 1);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Verify only kandang 1 was deleted
|
||||
const record1 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 1);
|
||||
const record2 = await Mortality.getByDay(TEST_CYCLE_ID, 1, 2);
|
||||
expect(record1).toBeNull();
|
||||
expect(record2).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRecord', () => {
|
||||
it('should format database record to camelCase', () => {
|
||||
const dbRecord = {
|
||||
id: 1,
|
||||
cycle_id: TEST_CYCLE_ID,
|
||||
day: 5,
|
||||
mortality_count: 50,
|
||||
chicken_count: 9500,
|
||||
population: 9500,
|
||||
kandang_id: 1,
|
||||
is_edited: true,
|
||||
afkir: 10,
|
||||
panen: 100,
|
||||
berat_panen: 12.5,
|
||||
keterangan: 'Test note',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
const formatted = Mortality.formatRecord(dbRecord);
|
||||
|
||||
expect(formatted.cycleId).toBe(TEST_CYCLE_ID);
|
||||
expect(formatted.mortalityCount).toBe(50);
|
||||
expect(formatted.chickenCount).toBe(9500);
|
||||
expect(formatted.kandangId).toBe(1);
|
||||
expect(formatted.isEdited).toBe(true);
|
||||
expect(formatted.afkir).toBe(10);
|
||||
expect(formatted.panen).toBe(100);
|
||||
expect(formatted.beratPanen).toBe(12.5);
|
||||
expect(formatted.keterangan).toBe('Test note');
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+2358
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "chicken-farm-backend",
|
||||
"version": "2.0.0",
|
||||
"description": "Backend API for chicken farm dashboard with PostgreSQL database",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "node server.js",
|
||||
"seed": "node database/seed-postgres.js",
|
||||
"start": "node server.js",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"postgresql",
|
||||
"express",
|
||||
"chicken-farm"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/ui": "^4.1.5",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const AiInsight = require('../models/AiInsight.js');
|
||||
const {
|
||||
generateDashboardInsight,
|
||||
INSIGHT_VERSION,
|
||||
} = require('../services/dashboardInsightService.js');
|
||||
|
||||
|
||||
// GET /api/ai-insights/:cycleId
|
||||
// Optional query params: kandangId
|
||||
router.get('/:cycleId', async (req, res) => {
|
||||
try {
|
||||
const { cycleId } = req.params;
|
||||
const { kandangId } = req.query;
|
||||
|
||||
// Find the most recent non-expired insight
|
||||
const insight = await AiInsight.getByCycleAndKandang(cycleId, kandangId || null);
|
||||
|
||||
if (!insight) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'No insight found or insight expired',
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: insight,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI insight:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-insights
|
||||
// Body: { cycleId, kandangId, insightText, version }
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { cycleId, kandangId, insightText, version = 'v1' } = req.body;
|
||||
|
||||
if (!cycleId || !insightText) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'cycleId and insightText are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Upsert (create or update)
|
||||
const insight = await AiInsight.upsert({
|
||||
cycleId,
|
||||
kandangId: kandangId || null,
|
||||
insightText,
|
||||
version,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
data: insight,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving AI insight:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/ai-insights/cleanup
|
||||
// Cleanup expired insights (can be called by a cron job)
|
||||
router.delete('/cleanup', async (req, res) => {
|
||||
try {
|
||||
const deleted = await AiInsight.cleanupExpired();
|
||||
res.json({
|
||||
success: true,
|
||||
deleted,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up insights:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ai-insights/dashboard
|
||||
// Body: { cycleId, kandangId, contextPack, version, forceRefresh }
|
||||
router.post('/dashboard', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
cycleId,
|
||||
kandangId = null,
|
||||
contextPack,
|
||||
version = INSIGHT_VERSION,
|
||||
forceRefresh = false,
|
||||
} = req.body;
|
||||
|
||||
if (!cycleId || !contextPack) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'cycleId and contextPack are required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await generateDashboardInsight({
|
||||
cycleId,
|
||||
kandangId: kandangId || null,
|
||||
contextPack,
|
||||
version,
|
||||
forceRefresh: Boolean(forceRefresh),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
source: result.source,
|
||||
data: result.insight,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating AI dashboard insight:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── RAG: Fetch relevant CP707 chunks from Python RAG service ────────────────
|
||||
const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://localhost:5002';
|
||||
|
||||
async function fetchCp707Chunks(query, topic, nResults = 4) {
|
||||
try {
|
||||
const response = await fetch(`${RAG_SERVICE_URL}/query`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, topic, n_results: nResults }),
|
||||
signal: AbortSignal.timeout(5000) // max 5 detik untuk RAG query
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const json = await response.json();
|
||||
return json.chunks || [];
|
||||
} catch (err) {
|
||||
// RAG service tidak wajib — graceful fallback jika tidak aktif
|
||||
console.warn('[RAG] Service tidak tersedia, lanjut tanpa RAG context:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/ai-insights/proxy
|
||||
// Proxies chat requests to LM Studio, auto-detecting the loaded model
|
||||
router.post('/proxy', async (req, res) => {
|
||||
try {
|
||||
let { input, system_prompt, temperature = 0.2, topic = '', context_query = '', contextData = null } = req.body;
|
||||
let responseText = '';
|
||||
let success = false;
|
||||
|
||||
// Format contextData cleanly as JSON if present
|
||||
if (contextData && typeof contextData === 'object') {
|
||||
const dataStr = JSON.stringify(contextData, null, 2);
|
||||
// Append formatted context data to user input if not already present
|
||||
if (!input.includes(dataStr)) {
|
||||
input = `${input}\n\n[Data Halaman (JSON)]:\n${dataStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Ambil chunk CP707 yang relevan dari RAG service
|
||||
const ragQuery = context_query || input.slice(0, 300); // gunakan konteks query atau 300 char pertama dari input
|
||||
const cp707Chunks = await fetchCp707Chunks(ragQuery, topic, 4);
|
||||
|
||||
// 2. Inject CP707 chunks ke dalam system_prompt
|
||||
let enrichedSystemPrompt = system_prompt || 'You are a helpful assistant.';
|
||||
if (cp707Chunks.length > 0) {
|
||||
const chunksText = cp707Chunks
|
||||
.map((chunk, i) => `[Referensi CP707 #${i + 1}]\n${chunk}`)
|
||||
.join('\n\n');
|
||||
enrichedSystemPrompt = `${enrichedSystemPrompt}
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
REFERENSI BUKU: Manajemen Broiler CP 707
|
||||
(PT Charoen Pokphand Indonesia, Tbk - Edisi Juli 2023)
|
||||
GUNAKAN informasi ini sebagai acuan utama analisis Anda.
|
||||
═══════════════════════════════════════════════
|
||||
${chunksText}
|
||||
═══════════════════════════════════════════════`;
|
||||
console.log(`[RAG] Injected ${cp707Chunks.length} CP707 chunks into system_prompt`);
|
||||
}
|
||||
|
||||
let isOllama = false;
|
||||
|
||||
// 3. Try LM Studio / Ollama
|
||||
try {
|
||||
const lmStudioBaseUrl = process.env.LM_STUDIO_BASE_URL || process.env.OLLAMA_BASE_URL ||
|
||||
(process.env.DOCKER_ENV === 'true' ? 'http://host.docker.internal:11434' : 'http://127.0.0.1:11434');
|
||||
isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm');
|
||||
let modelName = process.env.LLM_MODEL_NAME || 'deepseek-r1:8b';
|
||||
|
||||
// Only auto-detect if LLM_MODEL_NAME is not explicitly configured
|
||||
if (!process.env.LLM_MODEL_NAME) {
|
||||
try {
|
||||
const modelsResponse = await fetch(`${lmStudioBaseUrl}/api/v1/models`, { signal: AbortSignal.timeout(2000) });
|
||||
if (modelsResponse.ok) {
|
||||
const modelsJson = await modelsResponse.json();
|
||||
if (modelsJson && modelsJson.data && modelsJson.data.length > 0) {
|
||||
modelName = modelsJson.data[0].id;
|
||||
} else if (modelsJson && Array.isArray(modelsJson.models) && modelsJson.models.length > 0) {
|
||||
modelName = modelsJson.models[0].key;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[LM Studio Proxy] Failed to fetch loaded models:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions';
|
||||
|
||||
const payload = isOllama ? {
|
||||
model: modelName,
|
||||
messages: [
|
||||
{ role: 'system', content: enrichedSystemPrompt },
|
||||
{ role: 'user', content: input }
|
||||
],
|
||||
stream: false,
|
||||
options: {
|
||||
num_ctx: 8192,
|
||||
num_predict: 8000,
|
||||
temperature
|
||||
}
|
||||
} : {
|
||||
model: modelName,
|
||||
messages: [
|
||||
{ role: 'system', content: enrichedSystemPrompt },
|
||||
{ role: 'user', content: input }
|
||||
],
|
||||
temperature,
|
||||
max_tokens: 8000
|
||||
};
|
||||
|
||||
const response = await fetch(`${lmStudioBaseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(540000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
const msg = isOllama ? json.message : json.choices?.[0]?.message;
|
||||
const finishReason = isOllama ? json.done_reason : json.choices?.[0]?.finish_reason;
|
||||
console.log(`[LM Studio Proxy] finish_reason=${finishReason}, content_len=${msg?.content?.length || 0}`);
|
||||
|
||||
if (msg?.content && typeof msg.content === 'string' && msg.content.trim()) {
|
||||
responseText = msg.content.trim();
|
||||
} else if (msg?.reasoning_content && typeof msg.reasoning_content === 'string' && msg.reasoning_content.trim()) {
|
||||
responseText = msg.reasoning_content.trim();
|
||||
console.log('[LM Studio Proxy] content empty, using reasoning_content as fallback');
|
||||
} else if (!isOllama && json.content && typeof json.content === 'string') {
|
||||
responseText = json.content;
|
||||
} else if (!isOllama && json.response) {
|
||||
responseText = json.response;
|
||||
} else if (!isOllama && Array.isArray(json.output)) {
|
||||
const parts = json.output
|
||||
.map(item => (typeof item.content === 'string' ? item.content : ''))
|
||||
.filter(Boolean);
|
||||
responseText = parts.join('\n').trim();
|
||||
}
|
||||
success = !!responseText;
|
||||
} else {
|
||||
const errBody = await response.text().catch(() => '');
|
||||
console.warn(`[LM Studio Proxy] Returned status ${response.status}: ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
} catch (lmStudioError) {
|
||||
console.error('[LM Studio Proxy] Failed/not running:', lmStudioError.message);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
const errMsg = isOllama ? 'Gagal menghubungi Ollama lokal. Pastikan Ollama aktif di port 11434.' : 'Gagal menghubungi LM Studio lokal. Pastikan server LM Studio aktif di port 1234.';
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
const cleanedResponseText = responseText
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
||||
.replace(/```json\s*/gi, '')
|
||||
.replace(/```\s*/g, '')
|
||||
.trim();
|
||||
res.json({ success: true, response: cleanedResponseText });
|
||||
} catch (error) {
|
||||
console.error('Error in proxy route:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
const express = require('express');
|
||||
|
||||
// Mock dependencies of backend/routes/aiInsights.js before loading it
|
||||
vi.mock('../models/AiInsight.js', () => {
|
||||
return {
|
||||
getByCycleAndKandang: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
cleanupExpired: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/dashboardInsightService.js', () => {
|
||||
return {
|
||||
generateDashboardInsight: vi.fn(),
|
||||
INSIGHT_VERSION: 'v1',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock database module so it doesn't attempt real connection during test
|
||||
vi.mock('../database/db', () => ({}));
|
||||
|
||||
// Import express router
|
||||
const router = require('./aiInsights.js');
|
||||
|
||||
describe('AI Insights Proxy Route', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/ai-insights', router);
|
||||
});
|
||||
|
||||
it('should accept contextData and send it to LM Studio proxy with CP707 chunks', async () => {
|
||||
// 1. Mock fetch globally
|
||||
const mockResponseText = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
headline: 'Analisis Test FCR',
|
||||
status: 'ok',
|
||||
summary: ['FCR normal'],
|
||||
risks: [],
|
||||
actions: ['Pertahankan'],
|
||||
}),
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation((url, options) => {
|
||||
// Mock /api/v1/models (checking loaded models)
|
||||
if (url.includes('/api/v1/models')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ id: 'mock-model' }] }),
|
||||
});
|
||||
}
|
||||
|
||||
// Mock RAG Service on port 5002
|
||||
if (url.includes(':5002/query')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
chunks: ['Ini adalah chunk standar FCR broiler CP 707 dari buku.'],
|
||||
sources: ['page 1'],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Mock LM Studio chat completions
|
||||
if (url.includes('/v1/chat/completions')) {
|
||||
const body = JSON.parse(options.body);
|
||||
|
||||
// Assert that body.messages contains system prompt with RAG chunks
|
||||
expect(body.messages[0].role).toBe('system');
|
||||
expect(body.messages[0].content).toContain('REFERENSI BUKU: Manajemen Broiler CP 707');
|
||||
expect(body.messages[0].content).toContain('Ini adalah chunk standar FCR broiler CP 707 dari buku.');
|
||||
|
||||
// Assert that body.messages contains user message with contextData
|
||||
expect(body.messages[1].role).toBe('user');
|
||||
expect(body.messages[1].content).toContain('"FCR_aktual": 1.25');
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(JSON.parse(mockResponseText)),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unhandled fetch url: ${url}`));
|
||||
});
|
||||
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Simulate calling the endpoint using standard express request-response handling
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: '/api/ai-insights/proxy',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: {
|
||||
topic: 'fcr',
|
||||
context_query: 'FCR standar broiler CP707',
|
||||
input: 'Data: FCR_aktual 1.25',
|
||||
contextData: { FCR_aktual: 1.25 },
|
||||
system_prompt: 'Anda adalah analis CP707',
|
||||
},
|
||||
};
|
||||
|
||||
// We can run a small integration check by calling the router directly
|
||||
let resObj;
|
||||
await new Promise((resolve) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
setHeader(name, val) {
|
||||
this.headers[name.toLowerCase()] = val;
|
||||
},
|
||||
status(code) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(data) {
|
||||
this.body = data;
|
||||
resObj = this;
|
||||
resolve();
|
||||
},
|
||||
};
|
||||
|
||||
app(req, res, (err) => {
|
||||
if (err) console.error('Router error:', err);
|
||||
resObj = res;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
expect(resObj.statusCode).toBe(200);
|
||||
expect(resObj.body.success).toBe(true);
|
||||
expect(JSON.parse(resObj.body.response).headline).toBe('Analisis Test FCR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ApiKey = require('../models/ApiKey');
|
||||
|
||||
// Note: In production, these routes should be protected by admin authentication
|
||||
// For now, they are unprotected for initial setup
|
||||
|
||||
// POST /api/admin/api-keys - Create new API key
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, description, createdBy, expiresAt, rateLimit, allowedIps } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'name is required'
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = await ApiKey.create({
|
||||
name,
|
||||
description,
|
||||
createdBy,
|
||||
expiresAt,
|
||||
rateLimit,
|
||||
allowedIps
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: apiKey,
|
||||
warning: 'Save this key securely. It will not be shown again.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/api-keys - List all API keys
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const keys = await ApiKey.getAll();
|
||||
res.json({
|
||||
success: true,
|
||||
data: keys
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting API keys:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/api-keys/:id - Get specific API key
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const key = await ApiKey.getById(req.params.id);
|
||||
if (!key) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API key not found'
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: key
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting API key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/api-keys/:id - Update API key status
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { isActive } = req.body;
|
||||
|
||||
if (isActive === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'isActive is required'
|
||||
});
|
||||
}
|
||||
|
||||
const key = await ApiKey.updateStatus(req.params.id, isActive);
|
||||
|
||||
if (!key) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API key not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: key
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating API key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/admin/api-keys/:id - Delete API key
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const deleted = await ApiKey.delete(req.params.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'API key not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'API key deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,107 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const AuditLog = require('../models/AuditLog');
|
||||
|
||||
/**
|
||||
* GET /api/audit-logs
|
||||
* Get audit logs with optional filtering
|
||||
*
|
||||
* Query parameters:
|
||||
* - table: Filter by table name (e.g., 'mortality_records')
|
||||
* - recordId: Filter by specific record ID
|
||||
* - startDate: Filter by start date (YYYY-MM-DD)
|
||||
* - endDate: Filter by end date (YYYY-MM-DD)
|
||||
* - limit: Maximum number of logs to return (default: 50)
|
||||
* - offset: Number of logs to skip for pagination (default: 0)
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { table, recordId, startDate, endDate, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
let logs;
|
||||
|
||||
// Filter by specific record
|
||||
if (table && recordId) {
|
||||
logs = await AuditLog.getByRecord(table, recordId);
|
||||
}
|
||||
// Filter by date range
|
||||
else if (startDate && endDate) {
|
||||
logs = await AuditLog.getByDateRange(
|
||||
table || null,
|
||||
startDate,
|
||||
endDate,
|
||||
parseInt(limit, 10)
|
||||
);
|
||||
}
|
||||
// Filter by table
|
||||
else if (table) {
|
||||
logs = await AuditLog.getByTable(
|
||||
table,
|
||||
parseInt(limit, 10),
|
||||
parseInt(offset, 10)
|
||||
);
|
||||
}
|
||||
// Get recent logs across all tables
|
||||
else {
|
||||
logs = await AuditLog.getRecent(parseInt(limit, 10));
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
let totalCount = logs.length;
|
||||
if (table && !recordId) {
|
||||
totalCount = await AuditLog.getCount(table);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: logs,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit: parseInt(limit, 10),
|
||||
offset: parseInt(offset, 10),
|
||||
hasMore: totalCount > (parseInt(offset, 10) + logs.length)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/audit-logs/count
|
||||
* Get count of audit logs for a table
|
||||
*
|
||||
* Query parameters:
|
||||
* - table: Table name (required)
|
||||
*/
|
||||
router.get('/count', async (req, res) => {
|
||||
try {
|
||||
const { table } = req.query;
|
||||
|
||||
if (!table) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'table parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
const count = await AuditLog.getCount(table);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error counting audit logs:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,250 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ChickenCount = require('../models/ChickenCount');
|
||||
const Kandang = require('../models/Kandang');
|
||||
const { validateApiKey } = require('../middleware/auth');
|
||||
const {
|
||||
isBeforeCutoffTime,
|
||||
getPreviousDate,
|
||||
createResponseMetadata,
|
||||
} = require('../utils/dateUtils');
|
||||
|
||||
/**
|
||||
* Normalize a kandang name to a slug: "Kandang Atas" -> "kandang-atas"
|
||||
*/
|
||||
function nameToSlug(name) {
|
||||
return name.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// POST /api/chicken-counting (requires API key)
|
||||
router.post('/', validateApiKey, async (req, res) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Check if body is empty or not JSON
|
||||
if (!req.body || Object.keys(req.body).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Request body is empty or not valid JSON. Make sure to set Content-Type: application/json',
|
||||
});
|
||||
}
|
||||
|
||||
const { date, kandang, filename, total_count } = req.body;
|
||||
|
||||
// Check missing fields individually for clear error messages
|
||||
const missingFields = [];
|
||||
if (!date) missingFields.push('date');
|
||||
if (!kandang) missingFields.push('kandang');
|
||||
if (total_count === undefined || total_count === null) missingFields.push('total_count');
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Missing required fields: ${missingFields.join(', ')}`,
|
||||
details: {
|
||||
missing: missingFields,
|
||||
received: {
|
||||
date: date || null,
|
||||
kandang: kandang || null,
|
||||
total_count: total_count !== undefined ? total_count : null,
|
||||
filename: filename || null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if (typeof date !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid date type. Expected string, got ${typeof date}`,
|
||||
details: { received: date, expected: 'YYYY-MM-DD' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid date format: "${date}". Use YYYY-MM-DD (e.g. "2026-03-01")`,
|
||||
details: { received: date, expected: 'YYYY-MM-DD' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate date is a real date
|
||||
const parsedDate = new Date(date + 'T00:00:00Z');
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid date value: "${date}". The date does not exist.`,
|
||||
details: { received: date },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate kandang type
|
||||
if (typeof kandang !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid kandang type. Expected string, got ${typeof kandang}`,
|
||||
details: { received: kandang, expected: 'string (e.g. "kandang-atas")' },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate total_count type and value
|
||||
if (typeof total_count !== 'number') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid total_count type. Expected number, got ${typeof total_count}`,
|
||||
details: { received: total_count, expected: 'number (e.g. 5612)' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!Number.isInteger(total_count)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `total_count must be an integer, got ${total_count}`,
|
||||
details: { received: total_count },
|
||||
});
|
||||
}
|
||||
|
||||
if (total_count < 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `total_count cannot be negative`,
|
||||
details: { received: total_count },
|
||||
});
|
||||
}
|
||||
|
||||
// Validate filename type if provided
|
||||
if (filename !== undefined && filename !== null && typeof filename !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid filename type. Expected string, got ${typeof filename}`,
|
||||
details: { received: filename },
|
||||
});
|
||||
}
|
||||
let allKandangs;
|
||||
try {
|
||||
allKandangs = await Kandang.getAll();
|
||||
} catch (dbError) {
|
||||
console.error(`[${timestamp}] ERROR: Failed to query kandangs table:`, dbError.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Database error: Failed to query kandangs table',
|
||||
details: { message: dbError.message },
|
||||
});
|
||||
}
|
||||
|
||||
if (allKandangs.length === 0) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
'No kandangs configured in the system. Please create kandangs first via /api/kandangs',
|
||||
});
|
||||
}
|
||||
|
||||
const matched = allKandangs.find((k) => nameToSlug(k.name) === kandang);
|
||||
const validSlugs = allKandangs.map((k) => ({
|
||||
slug: nameToSlug(k.name),
|
||||
name: k.name,
|
||||
id: k.id,
|
||||
}));
|
||||
|
||||
if (!matched) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Kandang '${kandang}' not found`,
|
||||
details: {
|
||||
received: kandang,
|
||||
available: validSlugs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this is a manual update from UI (source=manual query param)
|
||||
const isManualUpdate = req.query.source === 'manual';
|
||||
const operation = isManualUpdate ? 'Upserting (manual)' : 'Inserting (API, skip if exists)';
|
||||
|
||||
// Use upsert for manual updates, insertIfNotExists for API calls
|
||||
let record;
|
||||
try {
|
||||
if (isManualUpdate) {
|
||||
record = await ChickenCount.upsert(date, matched.id, filename || null, total_count);
|
||||
} else {
|
||||
record = await ChickenCount.insertIfNotExists(
|
||||
date,
|
||||
matched.id,
|
||||
filename || null,
|
||||
total_count
|
||||
);
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error(`[${timestamp}] ERROR: Failed to save chicken count:`, dbError.message);
|
||||
console.error(`[${timestamp}] Stack:`, dbError.stack);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Database error: Failed to save chicken count',
|
||||
details: { message: dbError.message },
|
||||
});
|
||||
}
|
||||
|
||||
if (record.skipped) {
|
||||
} else {
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: record,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[${timestamp}] UNEXPECTED ERROR:`, error.message);
|
||||
console.error(`[${timestamp}] Stack:`, error.stack);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: { message: error.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/chicken-counting?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&kandangId=1
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
let { startDate, endDate, kandangId } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'startDate and endDate query parameters are required (YYYY-MM-DD)',
|
||||
});
|
||||
}
|
||||
|
||||
// Apply D-1 filter if querying single day and before cutoff (17:00 WIB)
|
||||
let isD1Applied = false;
|
||||
if (startDate === endDate && isBeforeCutoffTime()) {
|
||||
startDate = getPreviousDate(startDate);
|
||||
endDate = startDate;
|
||||
isD1Applied = true;
|
||||
}
|
||||
|
||||
const records = await ChickenCount.getByDateRange(
|
||||
startDate,
|
||||
endDate,
|
||||
kandangId ? parseInt(kandangId) : null
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: records,
|
||||
meta: createResponseMetadata(isD1Applied),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error querying chicken counting:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,84 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const CycleFeedInitialBalance = require('../models/CycleFeedInitialBalance');
|
||||
const { convertKeysToCamelCase } = require('../utils/caseConverter');
|
||||
|
||||
/**
|
||||
* GET /api/cycle-feed-initial-balance/:cycleId
|
||||
* Get initial balance for a specific cycle
|
||||
*/
|
||||
router.get('/:cycleId', async (req, res) => {
|
||||
try {
|
||||
const { cycleId } = req.params;
|
||||
const balance = await CycleFeedInitialBalance.getByCycleId(cycleId);
|
||||
|
||||
if (!balance) {
|
||||
return res.json({ success: true, data: null });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: convertKeysToCamelCase(balance) });
|
||||
} catch (error) {
|
||||
console.error('Error fetching cycle feed initial balance:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cycle-feed-initial-balance
|
||||
* Upsert initial balance for a cycle
|
||||
*/
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
const { cycleId, saldoAwal, createdBy } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!cycleId || saldoAwal === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: cycleId, saldoAwal'
|
||||
});
|
||||
}
|
||||
|
||||
if (saldoAwal < 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'saldoAwal cannot be negative'
|
||||
});
|
||||
}
|
||||
|
||||
if (!Number.isInteger(saldoAwal)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'saldoAwal must be an integer'
|
||||
});
|
||||
}
|
||||
|
||||
const balance = await CycleFeedInitialBalance.upsert(
|
||||
cycleId,
|
||||
saldoAwal,
|
||||
createdBy || 'system'
|
||||
);
|
||||
|
||||
res.json({ success: true, data: convertKeysToCamelCase(balance) });
|
||||
} catch (error) {
|
||||
console.error('Error saving cycle feed initial balance:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/cycle-feed-initial-balance/:cycleId
|
||||
* Delete initial balance for a cycle
|
||||
*/
|
||||
router.delete('/:cycleId', async (req, res) => {
|
||||
try {
|
||||
const { cycleId } = req.params;
|
||||
await CycleFeedInitialBalance.delete(cycleId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting cycle feed initial balance:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user