98 lines
3.1 KiB
TypeScript
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',
|
|
};
|
|
}
|