import type { ChickenCounting, ChickenWeight, Cycle, Karung, KPI, ManualInput, } from '../types/api.ts'; import type { DashboardReportPayload, DashboardReportType } from '../types/dashboardReport.ts'; import { cycleDayForDate, cycleLastDayIndex, resolveCycleBoundDate } from './format.ts'; export type BuildInMemoryReportInput = { cycle: Cycle; kandangId: number; kandangName: string; area: string; reportType: DashboardReportType; countings: ChickenCounting[]; manuals: ManualInput[]; weights: ChickenWeight[]; karungs: Karung[]; kpis: KPI[]; generatedAt?: string; /** Override the report's last day (0-based); defaults to current day / cycle end. */ upToDay?: number; }; function accuracy(iot: number | null, manual: number | null): number | null { if (iot == null || manual == null || manual === 0) return null; return Math.round((1 - Math.abs(iot - manual) / Math.abs(manual)) * 1000) / 10; } function countingAccuracy(iot: number | null, stockAkhir: number | null): number | null { if (iot == null || stockAkhir == null || stockAkhir <= 0) return null; return Math.round((Math.min(iot, stockAkhir) / Math.max(iot, stockAkhir)) * 1000) / 10; } /** Feed accuracy: both manual and IoT reading zero means a perfect match (100%); * if only one side has a reading, the match is 0%. */ function feedAccuracy(iot: number | null, manual: number | null): number | null { if (manual != null && iot != null) { if (manual === 0 && iot === 0) return 100; if (manual === 0 || iot === 0) return 0; } return accuracy(iot, manual); } export function buildInMemoryReport(input: BuildInMemoryReportInput): DashboardReportPayload { const { cycle } = input; // Day numbering stays 0-based (day 0 = start_date), but the report must never // run past the approved end (or soft bound while open). const lastDay = cycleLastDayIndex(cycle); const endCycleUpTo = Math.min(cycle.total_days, lastDay); const defaultUpTo = input.reportType === 'end_cycle' ? endCycleUpTo : Math.min(Math.max(cycle.current_day, 0), lastDay); const upToDay = input.upToDay != null && Number.isFinite(input.upToDay) ? Math.min(Math.max(Math.floor(input.upToDay), 0), lastDay) : defaultUpTo; const days = Array.from({ length: Math.max(upToDay, 0) + 1 }, (_, day) => day); const countingByDay = new Map(); for (const row of input.countings) { countingByDay.set(cycleDayForDate(cycle.start_date, row.date), row); } const weightByDay = new Map(); for (const row of input.weights) { weightByDay.set(cycleDayForDate(cycle.start_date, row.date), row); } const karungByDay = new Map(); for (const row of input.karungs) { karungByDay.set(cycleDayForDate(cycle.start_date, row.date), row); } const kpiByAge = new Map(); for (const row of input.kpis) { kpiByAge.set(row.age, row); } const manualByDay = new Map(); for (const row of input.manuals) { manualByDay.set(cycleDayForDate(cycle.start_date, row.date), row); } const manualWeightByDay = new Map(); const manualFeedInByDay = new Map(); const manualFeedUseByDay = new Map(); const manualFeedOutByDay = new Map(); for (const row of input.manuals) { const day = cycleDayForDate(cycle.start_date, row.date); if (row.manual_weight > 0) manualWeightByDay.set(day, row.manual_weight); manualFeedInByDay.set(day, row.feed_in_manual); manualFeedUseByDay.set(day, row.feed_use_manual); manualFeedOutByDay.set(day, row.feed_out_manual); } let previousStockAkhir: number | null = null; const counting = days.map((day) => { const iot = countingByDay.get(day) ?? null; const kpi = kpiByAge.get(day) ?? null; const manual = manualByDay.get(day) ?? null; const iotCount = iot?.total_count ?? null; const mortality = manual?.mortality_manual ?? kpi?.mortality ?? iot?.mortality_count ?? null; const harvest = manual?.harvest_manual ?? kpi?.harvest ?? null; const harvestWeight = manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? null; const mati = mortality ?? 0; const panen = harvest ?? 0; const countBefore = day === 0 ? cycle.doc_in_count || 0 : (previousStockAkhir ?? 0); const countAfter = kpi != null ? Math.max(0, kpi.chicken_life - (kpi.harvest_total ?? 0)) : Math.max(0, countBefore - mati - panen); previousStockAkhir = countAfter; return { date: addDate(cycle.start_date, day), day, countBefore, mortality, harvest, harvestWeight, countAfter, iotCount, accuracy: countingAccuracy(iotCount, countAfter), }; }); const weight = days.map((day) => { const iot = weightByDay.get(day) ?? null; const avgIot = iot?.average_weight ?? null; const avgManual = manualWeightByDay.get(day) ?? null; return { date: addDate(cycle.start_date, day), day, avgManual, avgIot, uniformity: iot?.uniformity ?? null, difference: avgIot != null && avgManual != null ? avgIot - avgManual : null, accuracy: accuracy(avgIot, avgManual), }; }); const feedIn = days.map((day) => { const karung = karungByDay.get(day) ?? null; const iotIn = karung?.in_today ?? null; const manualIn = manualFeedInByDay.get(day) ?? null; return { date: addDate(cycle.start_date, day), manualIn, iotIn, accuracy: feedAccuracy(iotIn, manualIn), }; }); const feedUse = days.map((day) => { const karung = karungByDay.get(day) ?? null; const iotUse = karung?.feed_use_today ?? karung?.out_today ?? null; const manualUse = manualFeedUseByDay.get(day) ?? null; return { date: addDate(cycle.start_date, day), day, manualUse, iotUse, accuracy: feedAccuracy(iotUse, manualUse), }; }); const feedOut = days.map((day) => { const karung = karungByDay.get(day) ?? null; const iotOut = karung?.out_today ?? null; const manualOut = manualFeedOutByDay.get(day) ?? null; return { date: addDate(cycle.start_date, day), manualOut, iotOut, accuracy: feedAccuracy(iotOut, manualOut), }; }); const fcr = days.map((day) => { const kpi = kpiByAge.get(day) ?? null; const weightRow = weightByDay.get(day) ?? null; return { day, iotFcr: kpi?.fcr ?? null, feedTotal: kpi?.feed_total ?? null, iotWeight: weightRow?.average_weight ?? kpi?.iot_weight ?? null, chickenLife: kpi?.chicken_life ?? null, harvestWeightTotal: kpi?.harvest_weight_total ?? null, }; }); const eef = days.map((day) => { const kpi = kpiByAge.get(day) ?? null; return { day, iotEef: kpi?.eef ?? null, chickenLifePercentage: kpi?.chicken_life_percentage ?? null, iotAverageHarvestBw: kpi?.average_harvest_weight ?? null, iotAverageDayWeight: kpi?.iot_weight ?? null, iotFcr: kpi?.fcr ?? null, }; }); return { cycleId: String(cycle.id), cycleStart: cycle.start_date, cycleEnd: cycle.end_date ?? resolveCycleBoundDate(cycle), kandangId: input.kandangId, kandangName: input.kandangName, area: input.area, upToDay, docWeight: typeof cycle.chick_in_weight === 'number' && Number.isFinite(cycle.chick_in_weight) ? cycle.chick_in_weight : null, generatedAt: input.generatedAt ?? new Date().toISOString(), reportType: input.reportType, counting, weight, feedIn, feedUse, feedOut, fcr, eef, }; } function addDate(start: string, day: number): string { const date = new Date(`${start}T00:00:00`); date.setDate(date.getDate() + day); const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; }