Add dashboard report snapshots feature with CRUD operations. Implemented new database migrations for dashboard_report_snapshots, including report type handling. Updated backend routes and models to support snapshot retrieval and upsert functionality. Enhanced frontend components to integrate report snapshot management, including UI for daily and end-cycle reports. Added tests for new functionality to ensure reliability.
CI / lint-and-test (push) Canceled after 0s
CI / lint-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
059087be1d
commit
34336b9bd1
41 files changed
+4824
-178
No files matched your search
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { FeedSackColumnConfig } from '../../types/counting.ts';
|
||||
import { buildReportSnapshot } from '../buildReportSnapshot.ts';
|
||||
|
||||
const START = '2026-05-21T17:00:00.000Z'; // 22 Mei 2026 00:00 WIB
|
||||
|
||||
const COLUMNS: FeedSackColumnConfig[] = [
|
||||
{
|
||||
id: 'in-1',
|
||||
label: 'Karung Masuk K1',
|
||||
cameraName: 'kandang_1_karung_masuk',
|
||||
color: '#22c55e',
|
||||
type: 'inbound',
|
||||
kandangId: 1,
|
||||
},
|
||||
{
|
||||
id: 'use-1',
|
||||
label: 'Dituang K1',
|
||||
cameraName: 'kandang_1_karung_pakan',
|
||||
color: '#eab308',
|
||||
type: 'usage',
|
||||
kandangId: 1,
|
||||
},
|
||||
{
|
||||
id: 'use-2',
|
||||
label: 'Dituang K2',
|
||||
cameraName: 'kandang_2_karung_pakan',
|
||||
color: '#a855f7',
|
||||
type: 'usage',
|
||||
kandangId: 2,
|
||||
},
|
||||
{
|
||||
id: 'out-1',
|
||||
label: 'Keluar',
|
||||
cameraName: 'kandang_1_karung_keluar',
|
||||
color: '#ef4444',
|
||||
type: 'outbound',
|
||||
kandangId: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const baseInput = {
|
||||
cycleId: 'CYCLE-TEST',
|
||||
cycleStartDate: START,
|
||||
kandangId: 1,
|
||||
kandangName: 'Kandang 1',
|
||||
area: 'Sukawarna',
|
||||
upToDay: 2,
|
||||
initialPopulation: 100,
|
||||
generatedAt: '2026-05-24T10:00:00.000Z',
|
||||
mortalityRecords: [],
|
||||
chickenCountByDay: {} as Record<number, number>,
|
||||
feedSackHistory: [] as Array<{ cameraName: string; count: number; timestamp: Date | string }>,
|
||||
manualFeedEntries: [] as Array<{ cameraName: string; count: number; date: string }>,
|
||||
feedColumnConfig: COLUMNS,
|
||||
cageUuid: 'cage-1',
|
||||
weightByDay: [] as Array<{
|
||||
day: number;
|
||||
avgManual?: number | null;
|
||||
avgIot?: number | null;
|
||||
uniformity?: number | null;
|
||||
}>,
|
||||
};
|
||||
|
||||
describe('buildReportSnapshot', () => {
|
||||
it('decreases counting after-count by mortality+harvest only for days 0–2', () => {
|
||||
const snapshot = buildReportSnapshot({
|
||||
...baseInput,
|
||||
mortalityRecords: [
|
||||
{ day: 1, mortalityCount: 3, afkir: 2, panen: 1, beratPanen: 4.5 },
|
||||
{ day: 2, mortalityCount: 1, afkir: 0, panen: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.counting).toHaveLength(3);
|
||||
expect(snapshot.counting[0]).toMatchObject({
|
||||
day: 0,
|
||||
countBefore: 100,
|
||||
mortality: 0,
|
||||
harvest: 0,
|
||||
countAfter: 100,
|
||||
});
|
||||
expect(snapshot.counting[1]).toMatchObject({
|
||||
day: 1,
|
||||
countBefore: 100,
|
||||
mortality: 5,
|
||||
harvest: 1,
|
||||
harvestWeight: 4.5,
|
||||
countAfter: 94,
|
||||
});
|
||||
expect(snapshot.counting[2]).toMatchObject({
|
||||
day: 2,
|
||||
countBefore: 94,
|
||||
mortality: 1,
|
||||
harvest: 0,
|
||||
countAfter: 93,
|
||||
});
|
||||
});
|
||||
|
||||
it('combines left and right usage cameras for IoT and manual', () => {
|
||||
const snapshot = buildReportSnapshot({
|
||||
...baseInput,
|
||||
kandangName: 'Kandang Atas',
|
||||
upToDay: 0,
|
||||
feedColumnConfig: [
|
||||
{
|
||||
id: 'in-1',
|
||||
label: 'Karung Masuk',
|
||||
cameraName: 'kandang_1_karung_masuk',
|
||||
color: '#22c55e',
|
||||
type: 'inbound',
|
||||
},
|
||||
{
|
||||
id: 'use-atas',
|
||||
label: 'Karung Dituang Kandang Atas',
|
||||
cameraName: 'karung_tuang_feeder_kiri_atas',
|
||||
color: '#f97316',
|
||||
type: 'usage',
|
||||
},
|
||||
{
|
||||
id: 'use-bawah',
|
||||
label: 'Karung Dituang Kandang Bawah',
|
||||
cameraName: 'karung_tuang_feeder_kanan_bawah',
|
||||
color: '#a855f7',
|
||||
type: 'usage',
|
||||
},
|
||||
],
|
||||
feedSackHistory: [
|
||||
{
|
||||
cameraName: 'karung_tuang_feeder_kiri_atas',
|
||||
count: 15,
|
||||
timestamp: new Date(2026, 4, 22, 8, 0, 0),
|
||||
},
|
||||
{
|
||||
cameraName: 'karung_tuang_feeder_kanan_bawah',
|
||||
count: 15,
|
||||
timestamp: new Date(2026, 4, 22, 8, 0, 0),
|
||||
},
|
||||
],
|
||||
manualFeedEntries: [
|
||||
{ cameraName: 'karung_tuang_feeder_kiri_atas', count: 15, date: '2026-05-22' },
|
||||
{ cameraName: 'karung_tuang_feeder_kanan_bawah', count: 15, date: '2026-05-22' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.feedUse).toHaveLength(1);
|
||||
expect(snapshot.feedUse[0]?.iotUse).toBe(30);
|
||||
expect(snapshot.feedUse[0]?.manualUse).toBe(30);
|
||||
expect(snapshot.feedUse[0]?.accuracy).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps feed-in empty when inbound mapping fails, but still combines usage cameras', () => {
|
||||
const snapshot = buildReportSnapshot({
|
||||
...baseInput,
|
||||
kandangId: 99,
|
||||
upToDay: 0,
|
||||
feedSackHistory: [
|
||||
{
|
||||
cameraName: 'kandang_1_karung_pakan',
|
||||
count: 10,
|
||||
timestamp: new Date(2026, 4, 22, 8, 0, 0),
|
||||
},
|
||||
{
|
||||
cameraName: 'kandang_2_karung_pakan',
|
||||
count: 50,
|
||||
timestamp: new Date(2026, 4, 22, 8, 0, 0),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.feedUse[0]?.iotUse).toBe(60);
|
||||
expect(snapshot.feedUse[0]?.manualUse).toBe(0);
|
||||
expect(snapshot.feedIn[0]?.iotIn).toBeNull();
|
||||
expect(snapshot.feedIn[0]?.manualIn).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty weight rows without cageUuid but still builds a snapshot', () => {
|
||||
const snapshot = buildReportSnapshot({
|
||||
...baseInput,
|
||||
cageUuid: null,
|
||||
weightByDay: [{ day: 1, avgManual: 1000, avgIot: 900, uniformity: 80 }],
|
||||
});
|
||||
|
||||
expect(snapshot.cycleId).toBe('CYCLE-TEST');
|
||||
expect(snapshot.weight.every((row) => row.avgManual == null && row.avgIot == null)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('drops rows after the selected day', () => {
|
||||
const snapshot = buildReportSnapshot({
|
||||
...baseInput,
|
||||
upToDay: 8,
|
||||
mortalityRecords: [
|
||||
{ day: 8, mortalityCount: 1 },
|
||||
{ day: 9, mortalityCount: 99 },
|
||||
],
|
||||
weightByDay: [
|
||||
{ day: 9, avgManual: 2000, avgIot: 1900 }, // 1-based day 9 → report day 8
|
||||
{ day: 10, avgManual: 3000, avgIot: 2900 }, // report day 9 — dropped
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshot.counting.every((row) => row.day <= 8)).toBe(true);
|
||||
expect(snapshot.counting.some((row) => row.day === 9)).toBe(false);
|
||||
expect(snapshot.weight.some((row) => row.day === 8 && row.avgManual === 2000)).toBe(true);
|
||||
expect(snapshot.weight.some((row) => row.avgManual === 3000)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps 1-based feed day 1 on cycle start to Hari ke-0', () => {
|
||||
const snapshot = buildReportSnapshot({
|
||||
...baseInput,
|
||||
upToDay: 0,
|
||||
feedSackHistory: [
|
||||
{
|
||||
cameraName: 'kandang_1_karung_masuk',
|
||||
count: 7,
|
||||
timestamp: new Date(2026, 4, 22, 6, 0, 0),
|
||||
},
|
||||
],
|
||||
manualFeedEntries: [{ cameraName: 'kandang_1_karung_masuk', count: 7, date: '2026-05-22' }],
|
||||
});
|
||||
|
||||
expect(snapshot.feedIn).toHaveLength(1);
|
||||
expect(snapshot.feedIn[0] && 'day' in snapshot.feedIn[0]).toBe(false);
|
||||
expect(snapshot.feedIn[0]?.date).toBe('2026-05-22');
|
||||
expect(snapshot.feedIn[0]?.iotIn).toBe(7);
|
||||
expect(snapshot.feedIn[0]?.manualIn).toBe(7);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user