commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -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;
|
||||
@@ -0,0 +1,74 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Cycle = require('../models/Cycle');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const cycles = await Cycle.getAll();
|
||||
res.json({ success: true, data: cycles });
|
||||
} catch (error) {
|
||||
console.error('Error in GET /api/cycles:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/active', async (req, res) => {
|
||||
try {
|
||||
const cycle = await Cycle.getActive();
|
||||
if (!cycle) return res.status(404).json({ success: false, error: 'No active cycle found' });
|
||||
res.json({ success: true, data: cycle });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const cycle = await Cycle.getById(req.params.id);
|
||||
if (!cycle) return res.status(404).json({ success: false, error: 'Cycle not found' });
|
||||
res.json({ success: true, data: cycle });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { id, totalDays, currentDay, startDate, status } = req.body;
|
||||
if (!id || !totalDays || currentDay === undefined || !startDate || !status) {
|
||||
return res.status(400).json({ success: false, error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// CRITICAL: If creating a new "Active" cycle, automatically mark all previous "Active" cycles as "Completed"
|
||||
if (status === 'Active') {
|
||||
await Cycle.markAllActiveAsCompleted();
|
||||
}
|
||||
|
||||
const cycle = await Cycle.create(req.body);
|
||||
res.status(201).json({ success: true, data: cycle });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const cycle = await Cycle.update(req.params.id, req.body);
|
||||
if (!cycle) return res.status(404).json({ success: false, error: 'Cycle not found' });
|
||||
res.json({ success: true, data: cycle });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const deleted = await Cycle.delete(req.params.id);
|
||||
if (!deleted) return res.status(404).json({ success: false, error: 'Cycle not found' });
|
||||
res.json({ success: true, message: 'Cycle deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,29 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const FeedSackColumnConfig = require('../models/FeedSackColumnConfig');
|
||||
|
||||
// GET /api/feed-sack-column-config - Get column configuration
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const config = await FeedSackColumnConfig.get();
|
||||
res.json({ success: true, data: config || [] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/feed-sack-column-config - Save column configuration
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
const { config } = req.body;
|
||||
if (!Array.isArray(config)) {
|
||||
return res.status(400).json({ success: false, error: 'config must be an array' });
|
||||
}
|
||||
const saved = await FeedSackColumnConfig.save(config);
|
||||
res.json({ success: true, data: saved });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,133 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Kandang = require('../models/Kandang');
|
||||
|
||||
// GET /api/kandangs - List all kandangs
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const kandangs = await Kandang.getAll();
|
||||
res.json({ success: true, data: kandangs });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/kandangs/by-cycle/:cycleId - Get kandangs with doc_in_count for a cycle
|
||||
router.get('/by-cycle/:cycleId', async (req, res) => {
|
||||
try {
|
||||
const kandangs = await Kandang.getByCycle(req.params.cycleId);
|
||||
res.json({ success: true, data: kandangs });
|
||||
} catch (error) {
|
||||
console.error('Error fetching kandangs by cycle:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/kandangs/:id - Get single kandang
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
|
||||
const kandang = await Kandang.getById(id);
|
||||
if (!kandang) return res.status(404).json({ success: false, error: 'Kandang not found' });
|
||||
res.json({ success: true, data: kandang });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/kandangs - Create new kandang
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ success: false, error: 'Name is required' });
|
||||
}
|
||||
const kandang = await Kandang.create(name.trim());
|
||||
res.status(201).json({ success: true, data: kandang });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/kandangs/:id - Rename kandang
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
|
||||
const { name } = req.body;
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ success: false, error: 'Name is required' });
|
||||
}
|
||||
const kandang = await Kandang.update(id, name.trim());
|
||||
if (!kandang) return res.status(404).json({ success: false, error: 'Kandang not found' });
|
||||
res.json({ success: true, data: kandang });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/kandangs/:id - Delete kandang
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
|
||||
const deleted = await Kandang.delete(id);
|
||||
if (!deleted) return res.status(404).json({ success: false, error: 'Kandang not found' });
|
||||
res.json({ success: true, message: 'Kandang deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/kandangs/:id/cycle/:cycleId - Set doc_in_count for kandang + cycle
|
||||
router.put('/:id/cycle/:cycleId', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
|
||||
const { docInCount } = req.body;
|
||||
if (docInCount === undefined || docInCount < 0) {
|
||||
return res.status(400).json({ success: false, error: 'Valid docInCount is required' });
|
||||
}
|
||||
await Kandang.setDocInCount(id, req.params.cycleId, docInCount);
|
||||
res.json({ success: true, message: 'doc_in_count updated' });
|
||||
} catch (error) {
|
||||
console.error('Error setting DOC count:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/kandangs/:id/cage-uuid - Update cage UUID for a kandang
|
||||
router.put('/:id/cage-uuid', async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid ID' });
|
||||
|
||||
const { cageUuid } = req.body;
|
||||
|
||||
// Validate UUID format if provided (allow null/empty to clear)
|
||||
if (cageUuid && cageUuid.trim()) {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(cageUuid.trim())) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid UUID format' });
|
||||
}
|
||||
}
|
||||
|
||||
const kandang = await Kandang.updateCageUuid(id, cageUuid?.trim() || null);
|
||||
if (!kandang) {
|
||||
return res.status(404).json({ success: false, error: 'Kandang not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: kandang });
|
||||
} catch (error) {
|
||||
// Handle unique constraint violation
|
||||
if (error.code === '23505') {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ success: false, error: 'Cage UUID already assigned to another kandang' });
|
||||
}
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,128 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ManualFeedSack = require('../models/ManualFeedSack');
|
||||
const { convertKeysToCamelCase } = require('../utils/caseConverter');
|
||||
const {
|
||||
isBeforeCutoffTime,
|
||||
getPreviousDate,
|
||||
createResponseMetadata
|
||||
} = require('../utils/dateUtils');
|
||||
|
||||
/**
|
||||
* GET /api/manual-feed-sack?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
|
||||
* Get all manual entries for a date range
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
let { startDate, endDate } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required query parameters: startDate, endDate'
|
||||
});
|
||||
}
|
||||
|
||||
// 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 entries = await ManualFeedSack.getByDateRange(startDate, endDate);
|
||||
|
||||
// Transform to camelCase and ensure date is a string
|
||||
const transformedEntries = entries.map(entry => ({
|
||||
...convertKeysToCamelCase(entry),
|
||||
date: entry.date instanceof Date
|
||||
? entry.date.toISOString().split('T')[0]
|
||||
: typeof entry.date === 'string'
|
||||
? entry.date.split('T')[0]
|
||||
: entry.date
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: transformedEntries,
|
||||
meta: createResponseMetadata(isD1Applied)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching manual feed sack entries:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/manual-feed-sack
|
||||
* Upsert manual entry
|
||||
*/
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
const { date, cameraName, count, createdBy, keterangan } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!date || !cameraName || count === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: date, cameraName, count'
|
||||
});
|
||||
}
|
||||
|
||||
if (count < 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Count cannot be negative'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate keterangan length
|
||||
if (keterangan && keterangan.length > 200) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Keterangan cannot exceed 200 characters'
|
||||
});
|
||||
}
|
||||
|
||||
const entry = await ManualFeedSack.upsert(
|
||||
date,
|
||||
cameraName,
|
||||
count,
|
||||
createdBy,
|
||||
keterangan || null
|
||||
);
|
||||
|
||||
// Transform to camelCase and ensure date is a string
|
||||
const transformedEntry = {
|
||||
...convertKeysToCamelCase(entry),
|
||||
date: entry.date instanceof Date
|
||||
? entry.date.toISOString().split('T')[0]
|
||||
: typeof entry.date === 'string'
|
||||
? entry.date.split('T')[0]
|
||||
: entry.date
|
||||
};
|
||||
|
||||
res.json({ success: true, data: transformedEntry });
|
||||
} catch (error) {
|
||||
console.error('Error saving manual feed sack entry:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/manual-feed-sack/:date/:cameraName
|
||||
* Delete manual entry
|
||||
*/
|
||||
router.delete('/:date/:cameraName', async (req, res) => {
|
||||
try {
|
||||
const { date, cameraName } = req.params;
|
||||
await ManualFeedSack.delete(date, cameraName);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting manual feed sack entry:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,135 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Mortality = require('../models/Mortality');
|
||||
const { captureAuditLog, filterAuditFields } = require('../middleware/auditLogger');
|
||||
|
||||
router.get('/:cycleId', async (req, res) => {
|
||||
try {
|
||||
const kandangId = req.query.kandangId ? parseInt(req.query.kandangId, 10) : null;
|
||||
const records = await Mortality.getByCycle(req.params.cycleId, kandangId);
|
||||
res.json({ success: true, data: records });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET aggregated totals across all kandangs
|
||||
router.get('/:cycleId/totals', async (req, res) => {
|
||||
try {
|
||||
const totals = await Mortality.getTotalsByCycle(req.params.cycleId);
|
||||
res.json({ success: true, data: totals });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:cycleId/:day', async (req, res) => {
|
||||
try {
|
||||
const day = parseInt(req.params.day, 10);
|
||||
if (isNaN(day)) return res.status(400).json({ success: false, error: 'Day must be a number' });
|
||||
const kandangId = req.query.kandangId ? parseInt(req.query.kandangId, 10) : null;
|
||||
const record = await Mortality.getByDay(req.params.cycleId, day, kandangId);
|
||||
if (!record) return res.status(404).json({ success: false, error: 'Mortality record not found' });
|
||||
res.json({ success: true, data: record });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:cycleId/:day', async (req, res) => {
|
||||
try {
|
||||
const day = parseInt(req.params.day, 10);
|
||||
const { mortalityCount, chickenCount, kandangId, panen, keterangan, afkir, beratPanen } = req.body;
|
||||
|
||||
// Validation
|
||||
if (isNaN(day) || mortalityCount === undefined || mortalityCount < 0) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid mortality count' });
|
||||
}
|
||||
if (afkir !== undefined && afkir < 0) {
|
||||
return res.status(400).json({ success: false, error: 'Afkir must be non-negative' });
|
||||
}
|
||||
if (panen !== undefined && panen < 0) {
|
||||
return res.status(400).json({ success: false, error: 'Panen must be non-negative' });
|
||||
}
|
||||
if (beratPanen !== undefined && beratPanen < 0) {
|
||||
return res.status(400).json({ success: false, error: 'Berat panen must be non-negative' });
|
||||
}
|
||||
if (keterangan && keterangan.length > 200) {
|
||||
return res.status(400).json({ success: false, error: 'Keterangan must be 200 characters or less' });
|
||||
}
|
||||
|
||||
const parsedKandangId = kandangId ? parseInt(kandangId, 10) : null;
|
||||
const parsedAfkir = afkir !== undefined ? parseInt(afkir, 10) : 0;
|
||||
const parsedPanen = panen !== undefined ? parseInt(panen, 10) : 0;
|
||||
const parsedBeratPanen = beratPanen !== undefined ? parseFloat(beratPanen) : 0;
|
||||
const parsedKeterangan = keterangan || null;
|
||||
|
||||
if (beratPanen !== undefined && Number.isNaN(parsedBeratPanen)) {
|
||||
return res.status(400).json({ success: false, error: 'Berat panen must be a number' });
|
||||
}
|
||||
|
||||
// Fetch old record for audit logging
|
||||
const oldRecord = await Mortality.getByDay(req.params.cycleId, day, parsedKandangId);
|
||||
const isCreate = !oldRecord;
|
||||
|
||||
// Perform update/insert
|
||||
const record = await Mortality.upsert(
|
||||
req.params.cycleId,
|
||||
day,
|
||||
mortalityCount,
|
||||
true,
|
||||
chickenCount,
|
||||
parsedKandangId,
|
||||
parsedPanen,
|
||||
parsedKeterangan,
|
||||
parsedAfkir,
|
||||
parsedBeratPanen
|
||||
);
|
||||
|
||||
// Capture audit log
|
||||
const recordId = `${req.params.cycleId}-${day}${parsedKandangId ? `-${parsedKandangId}` : ''}`;
|
||||
await captureAuditLog(
|
||||
'mortality_records',
|
||||
recordId,
|
||||
isCreate ? 'CREATE' : 'UPDATE',
|
||||
isCreate ? null : filterAuditFields(oldRecord),
|
||||
filterAuditFields(record)
|
||||
);
|
||||
|
||||
res.json({ success: true, data: record });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:cycleId/:day', async (req, res) => {
|
||||
try {
|
||||
const day = parseInt(req.params.day, 10);
|
||||
if (isNaN(day)) return res.status(400).json({ success: false, error: 'Day must be a number' });
|
||||
const kandangId = req.query.kandangId ? parseInt(req.query.kandangId, 10) : null;
|
||||
|
||||
// Fetch record before deleting for audit log
|
||||
const oldRecord = await Mortality.getByDay(req.params.cycleId, day, kandangId);
|
||||
|
||||
const deleted = await Mortality.delete(req.params.cycleId, day, kandangId);
|
||||
if (!deleted) return res.status(404).json({ success: false, error: 'Mortality record not found' });
|
||||
|
||||
// Capture audit log
|
||||
if (oldRecord) {
|
||||
const recordId = `${req.params.cycleId}-${day}${kandangId ? `-${kandangId}` : ''}`;
|
||||
await captureAuditLog(
|
||||
'mortality_records',
|
||||
recordId,
|
||||
'DELETE',
|
||||
filterAuditFields(oldRecord),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Mortality record deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in new issue
Block a user