Files
dashboard-cpsp/utils/countingAccuracyWarning.ts
T
2026-10-01 13:23:45 +07:00

98 lines
3.1 KiB
TypeScript

import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts';
import { addDaysIso, cycleDayForDate, cycleLastDayIndex } from './format.ts';
import { stockAkhirFromKpi } from './kpiStock.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 = cycleLastDayIndex(cycle);
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 = stockAkhirFromKpi(kpi) ?? 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',
};
}