220 lines
5.9 KiB
TypeScript
220 lines
5.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../../types/api.ts';
|
|
import { getCountingAccuracyWarning } from '../countingAccuracyWarning.ts';
|
|
|
|
const cycle = {
|
|
id: 1,
|
|
kandang: 1,
|
|
kandang_name: 'K1',
|
|
total_days: 35,
|
|
current_day: 5,
|
|
visible_through_date: '2026-08-15',
|
|
start_date: '2026-08-10',
|
|
end_date: '2026-09-13',
|
|
chick_in_weight: 45,
|
|
doc_in_count: 1000,
|
|
feed_initial_balance: 0,
|
|
status: 'active',
|
|
created_at: '',
|
|
updated_at: '',
|
|
} satisfies Cycle;
|
|
|
|
function kpi(partial: Partial<KPI> & Pick<KPI, 'date' | 'chicken_life'>): KPI {
|
|
return {
|
|
id: 1,
|
|
cycle: 1,
|
|
kandang: 1,
|
|
flock_id: 1,
|
|
age: 0,
|
|
mortality: 0,
|
|
mortality_total: 0,
|
|
feed: 0,
|
|
feed_total: 0,
|
|
harvest: 0,
|
|
harvest_total: 0,
|
|
harvest_weight: 0,
|
|
harvest_weight_total: 0,
|
|
chicken_life_percentage: 100,
|
|
iot_weight: 0,
|
|
average_harvest_day: 0,
|
|
average_harvest_weight: 0,
|
|
fcr: 0,
|
|
eef: 0,
|
|
cc: 0,
|
|
cw: 0,
|
|
fs: 0,
|
|
manual: 0,
|
|
created_at: '',
|
|
updated_at: '',
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
function counting(
|
|
partial: Partial<ChickenCounting> & Pick<ChickenCounting, 'date' | 'total_count'>
|
|
): ChickenCounting {
|
|
return {
|
|
id: 1,
|
|
cycle: 1,
|
|
kandang: 1,
|
|
flock_id: 1,
|
|
mortality_count: 0,
|
|
created_at: '',
|
|
updated_at: '',
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
describe('getCountingAccuracyWarning', () => {
|
|
it('returns null when there is no IoT counting', () => {
|
|
expect(
|
|
getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [],
|
|
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
|
manualInputs: [],
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns under when latest IoT is below stock and accuracy <= 95', () => {
|
|
const result = getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [counting({ date: '2026-08-10', total_count: 930 })],
|
|
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
|
manualInputs: [],
|
|
});
|
|
expect(result).toMatchObject({
|
|
day: 0,
|
|
date: '2026-08-10',
|
|
iotCount: 930,
|
|
stockAkhir: 1000,
|
|
direction: 'under',
|
|
});
|
|
expect(result!.accuracy).toBe(93);
|
|
expect(result!.ratioPercent).toBe(93);
|
|
});
|
|
|
|
it('returns over when latest IoT is above stock and accuracy <= 95', () => {
|
|
// 1060/1000 → ratio 106%, accuracy min/max = 1000/1060 ≈ 94.3%
|
|
const result = getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [counting({ date: '2026-08-10', total_count: 1060 })],
|
|
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
|
manualInputs: [],
|
|
});
|
|
expect(result).toMatchObject({
|
|
day: 0,
|
|
iotCount: 1060,
|
|
stockAkhir: 1000,
|
|
direction: 'over',
|
|
});
|
|
expect(result!.accuracy).toBe(94.3);
|
|
expect(result!.ratioPercent).toBe(106);
|
|
});
|
|
|
|
it('warns when accuracy is exactly 95', () => {
|
|
const result = getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [counting({ date: '2026-08-10', total_count: 950 })],
|
|
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
|
manualInputs: [],
|
|
});
|
|
expect(result).toMatchObject({ accuracy: 95, direction: 'under' });
|
|
});
|
|
|
|
it('returns null when accuracy is above 95', () => {
|
|
expect(
|
|
getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [counting({ date: '2026-08-10', total_count: 960 })],
|
|
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
|
|
manualInputs: [],
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it('uses the latest IoT day even if an older day is worse', () => {
|
|
const result = getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [
|
|
counting({ id: 1, date: '2026-08-10', total_count: 800 }), // worse under
|
|
counting({ id: 2, date: '2026-08-12', total_count: 970 }), // latest, accuracy 97% → no warn
|
|
],
|
|
kpis: [
|
|
kpi({ date: '2026-08-10', chicken_life: 1000 }),
|
|
kpi({ date: '2026-08-11', chicken_life: 1000 }),
|
|
kpi({ date: '2026-08-12', chicken_life: 1000 }),
|
|
],
|
|
manualInputs: [],
|
|
});
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('warns from the latest IoT day when that day is low', () => {
|
|
const result = getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [
|
|
counting({ id: 1, date: '2026-08-10', total_count: 990 }), // fine
|
|
counting({ id: 2, date: '2026-08-12', total_count: 900 }), // latest under
|
|
],
|
|
kpis: [
|
|
kpi({ date: '2026-08-10', chicken_life: 1000 }),
|
|
kpi({ date: '2026-08-11', chicken_life: 1000 }),
|
|
kpi({ date: '2026-08-12', chicken_life: 1000 }),
|
|
],
|
|
manualInputs: [],
|
|
});
|
|
expect(result).toMatchObject({
|
|
day: 2,
|
|
date: '2026-08-12',
|
|
iotCount: 900,
|
|
direction: 'under',
|
|
});
|
|
});
|
|
|
|
it('derives stock akhir from chain when chicken_life is missing', () => {
|
|
const manuals: ManualInput[] = [
|
|
{
|
|
id: 1,
|
|
cycle: 1,
|
|
kandang: 1,
|
|
flock_id: 1,
|
|
date: '2026-08-10',
|
|
age_manual: 0,
|
|
mortality_manual: 50,
|
|
mortality_manual_total: 50,
|
|
feed_in_manual: 0,
|
|
feed_use_manual: 0,
|
|
feed_in_manual_total: 0,
|
|
feed_use_manual_total: 0,
|
|
feed_out_manual: 0,
|
|
feed_out_manual_total: 0,
|
|
harvest_manual: 0,
|
|
harvest_manual_total: 0,
|
|
harvest_weight_manual: 0,
|
|
harvest_weight_manual_total: 0,
|
|
manual_weight: 0,
|
|
average_harvest_day_manual: 0,
|
|
average_harvest_weight_manual: 0,
|
|
fcr_manual: 0,
|
|
eef_manual: 0,
|
|
created_at: '',
|
|
updated_at: '',
|
|
},
|
|
];
|
|
const result = getCountingAccuracyWarning({
|
|
cycle,
|
|
countings: [counting({ date: '2026-08-10', total_count: 900 })],
|
|
kpis: [],
|
|
manualInputs: manuals,
|
|
});
|
|
// stock = 1000 - 50 = 950; IoT 900 → under, accuracy 900/950 ≈ 94.7
|
|
expect(result).toMatchObject({
|
|
stockAkhir: 950,
|
|
iotCount: 900,
|
|
direction: 'under',
|
|
});
|
|
});
|
|
});
|