update dashboard with a warning if the chicken counting accuracy result drop to less than 95%

This commit is contained in:
Alberto-Audrix committed 2026-09-07 15:40:31 +07:00
1 parent 155a24c7bd
commit 61fae64aa6
7 files changed
+479 -2

No files matched your search

@@ -0,0 +1,217 @@
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',
});
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts';
import { addDaysIso, cycleDayForDate } from './format.ts';
export const COUNTING_ACCURACY_WARNING_THRESHOLD = 95;
export type CountingAccuracyWarning = {
day: number;
date: string;
iotCount: number;
stockAkhir: number;
accuracy: number;
ratioPercent: number;
direction: 'over' | 'under';
};
export type CountingAccuracyWarningInput = {
cycle: Pick<Cycle, 'start_date' | 'end_date' | 'total_days' | 'doc_in_count'>;
countings: ChickenCounting[];
kpis: KPI[];
manualInputs: ManualInput[];
};
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function matchAccuracy(iot: number, stockAkhir: number): number {
return round1((Math.min(iot, stockAkhir) / Math.max(iot, stockAkhir)) * 100);
}
/** Build stock-akhir by date using the same rules as Hitung Ayam. */
function stockAkhirByDate(
cycle: CountingAccuracyWarningInput['cycle'],
kpis: KPI[],
manualInputs: ManualInput[]
): Map<string, number> {
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
const manualByDate = new Map(manualInputs.map((m) => [m.date, m]));
const lastCycleDay = Math.max(0, (cycle.total_days || 1) - 1);
const endDay = cycleDayForDate(cycle.start_date, cycle.end_date);
const maxDay = Math.min(lastCycleDay, endDay);
const result = new Map<string, number>();
let previousStockAkhir = 0;
for (let day = 0; day <= maxDay; day++) {
const date = addDaysIso(cycle.start_date, day);
const kpi = kpiByDate.get(date);
const manual = manualByDate.get(date);
const stockAwal = day === 0 ? cycle.doc_in_count || 0 : previousStockAkhir;
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
const stockAkhir = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen);
result.set(date, stockAkhir);
previousStockAkhir = stockAkhir;
}
return result;
}
function latestIotCounting(countings: ChickenCounting[]): ChickenCounting | null {
let best: ChickenCounting | null = null;
for (const row of countings) {
if (row.total_count == null) continue;
if (!best || row.date > best.date) best = row;
}
return best;
}
export function getCountingAccuracyWarning(
input: CountingAccuracyWarningInput
): CountingAccuracyWarning | null {
const latest = latestIotCounting(input.countings);
if (!latest) return null;
const stockMap = stockAkhirByDate(input.cycle, input.kpis, input.manualInputs);
const stockAkhir = stockMap.get(latest.date);
if (stockAkhir == null || stockAkhir <= 0) return null;
const iotCount = latest.total_count;
if (iotCount === stockAkhir) return null;
const accuracy = matchAccuracy(iotCount, stockAkhir);
if (accuracy > COUNTING_ACCURACY_WARNING_THRESHOLD) return null;
return {
day: cycleDayForDate(input.cycle.start_date, latest.date),
date: latest.date,
iotCount,
stockAkhir,
accuracy,
ratioPercent: round1((iotCount / stockAkhir) * 100),
direction: iotCount > stockAkhir ? 'over' : 'under',
};
}