commit
32a36cceff
444 files changed
+67186
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user