/** * Cross-domain evidence for the AI Insight cards. * * A page's own payload can only ever say WHAT happened on that page — "mortalitas * 9,1%, di atas standar". Answering WHY needs the other domains: the temperature * on the days the deaths spiked, the feed intake that fell first, the growth that * stalled. This module gathers those series once per cycle and hands them to the * card as a `diagnostik` bundle. * * The correlation itself is NOT done here — `backend/services/rootCauseAnalysis.js` * does it, so the dashboard path and every page path reach the same conclusions * from the same code. This file only collects and normalises. */ import { apiClient } from '../services/apiService.ts'; export interface DiagnostikBundle { hari_ke: number | null; total_hari?: number | null; populasi_awal_ekor: number | null; populasi_kini_ekor?: number | null; keseragaman_persen?: number | null; mortalitas_harian: Array<{ hari: number; mati: number; afkir: number; panen?: number }>; bobot_harian: Array<{ hari: number; aktual_gram: number; target_gram: number | null }>; fcr_harian: Array<{ hari: number; aktual: number; standar: number | null }>; pakan_harian: Array<{ hari: number; karung: number }>; lingkungan_harian: Array<{ hari: number; suhu_C: number; kelembapan_persen: number }>; } export interface DiagnostikInput { cycleId: string | null | undefined; /** * External coop UUID for the scales/sensor APIs. * * `getMultiCoopWeightData` returns null without it, so with "semua kandang" * selected the weight/feed/FCR series are simply absent — which the analysis * reports as missing data rather than as zeros. */ cageUuid: string | null | undefined; /** Internal kandang id, for scoping the mortality ledger on our own backend. */ kandangId?: number | null; startDate: Date | string | null | undefined; currentDay: number | null | undefined; totalDays?: number | null; populasiAwal?: number | null; populasiKini?: number | null; keseragaman?: number | null; apiMode?: 'LIVE' | 'DEMO'; /** * The mortality ledger, when the caller already has it. * * It is the authoritative record of deaths, culls and harvest — the KPI feed * carries deaths only, and reading a population drop as deaths is what once * made the counting page announce "mortalitas 100%". */ mortalityRows?: Array<{ day?: number | null; mortalityCount?: number | null; afkir?: number | null; panen?: number | null; }> | null; } const toNum = (value: unknown): number | null => { const parsed = typeof value === 'string' ? Number.parseFloat(value) : value; return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null; }; const isoDate = (date: Date): string => { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); return `${yyyy}-${mm}-${dd}`; }; /** * One fetch per cycle-day, shared by every card on every page. * * Six insight cards mounting in one session would otherwise pull the whole * cycle's sensor history six times. The day is part of the key so the data still * refreshes when the cycle moves on. */ const _bundleCache = new Map>(); const buildBundle = async (input: DiagnostikInput): Promise => { const currentDay = toNum(input.currentDay); const start = input.startDate ? new Date(input.startDate) : null; const mode = input.apiMode ?? 'LIVE'; // Both sources are optional: a missing one must degrade to "data tidak // tersedia" in the insight, never block the card from generating. // The ledger is fetched here when the caller has none of its own. It is the // only source that separates deaths from culls and from harvest, and it works // for "semua kandang" too — unlike the KPI feed, which needs a cage UUID. const needsLedger = !Array.isArray(input.mortalityRows) || input.mortalityRows.length === 0; const [kpiResult, envResult, ledgerResult] = await Promise.allSettled([ apiClient.getMultiCoopWeightData(mode, input.cageUuid ?? null), start && Number.isFinite(start.getTime()) ? apiClient.getIotDailyEnvironment(isoDate(start), isoDate(new Date()), mode) : Promise.resolve([]), needsLedger && input.cycleId ? apiClient.getMortalityRecords(input.cycleId, input.kandangId ?? undefined) : Promise.resolve([]), ]); const kpiRows: Record> = kpiResult.status === 'fulfilled' && kpiResult.value && typeof kpiResult.value === 'object' ? (kpiResult.value as Record>) : {}; const envRows = envResult.status === 'fulfilled' && Array.isArray(envResult.value) ? envResult.value : []; if (kpiResult.status === 'rejected') { console.warn('[insightEvidence] KPI harian tidak terambil:', kpiResult.reason); } if (envResult.status === 'rejected') { console.warn('[insightEvidence] Riwayat lingkungan tidak terambil:', envResult.reason); } if (ledgerResult.status === 'rejected') { console.warn('[insightEvidence] Catatan mortalitas tidak terambil:', ledgerResult.reason); } const mortalitas_harian: DiagnostikBundle['mortalitas_harian'] = []; const bobot_harian: DiagnostikBundle['bobot_harian'] = []; const fcr_harian: DiagnostikBundle['fcr_harian'] = []; const pakan_harian: DiagnostikBundle['pakan_harian'] = []; for (const [key, row] of Object.entries(kpiRows)) { const index = Number.parseInt(key, 10); if (!Number.isFinite(index) || !row || typeof row !== 'object') continue; // The KPI feed is keyed 0-based; every other series in the app counts days // from 1, and mixing the two shifts every correlation by a day. const hari = index + 1; const mati = toNum(row.mortality); if (mati !== null && mati >= 0) mortalitas_harian.push({ hari, mati, afkir: 0 }); const bobot = toNum(row.iot_weight) ?? toNum(row.manual_weight); if (bobot !== null && bobot > 0) { bobot_harian.push({ hari, aktual_gram: bobot, target_gram: toNum(row.ref_weight) }); } const fcr = toNum(row.iot_fcr) ?? toNum(row.manual_fcr); if (fcr !== null && fcr > 0) fcr_harian.push({ hari, aktual: fcr, standar: null }); const karung = toNum(row.food); if (karung !== null && karung >= 0) pakan_harian.push({ hari, karung }); } // The ledger wins over the KPI feed either way: it separates deaths from culls // and from harvest, which the KPI feed does not. const fetchedLedger = ledgerResult.status === 'fulfilled' && Array.isArray(ledgerResult.value) ? (ledgerResult.value as DiagnostikInput['mortalityRows']) : []; const ledger = !needsLedger && Array.isArray(input.mortalityRows) ? input.mortalityRows : (fetchedLedger ?? []); const ledgerRows = ledger .map((row) => ({ hari: toNum(row.day), mati: toNum(row.mortalityCount) ?? 0, afkir: toNum(row.afkir) ?? 0, panen: toNum(row.panen) ?? 0, })) .filter( (row): row is { hari: number; mati: number; afkir: number; panen: number } => row.hari !== null ); const sortByDay = (rows: T[]) => rows.sort((a, b) => a.hari - b.hari); const bundle: DiagnostikBundle = { hari_ke: currentDay, total_hari: toNum(input.totalDays), populasi_awal_ekor: toNum(input.populasiAwal), populasi_kini_ekor: toNum(input.populasiKini), keseragaman_persen: toNum(input.keseragaman), mortalitas_harian: sortByDay(ledgerRows.length > 0 ? ledgerRows : mortalitas_harian), bobot_harian: sortByDay(bobot_harian), fcr_harian: sortByDay(fcr_harian), pakan_harian: sortByDay(pakan_harian), lingkungan_harian: sortByDay( envRows.map((row) => ({ hari: row.hari, suhu_C: row.suhu_C, kelembapan_persen: row.kelembapan_persen, })) ), }; const hasAnySeries = bundle.mortalitas_harian.length > 0 || bundle.bobot_harian.length > 0 || bundle.pakan_harian.length > 0 || bundle.lingkungan_harian.length > 0; return hasAnySeries ? bundle : null; }; /** * The `diagnostik` bundle for a cycle, or null when nothing could be gathered. * * Never throws: a card that cannot get its evidence still has to render its * insight, only without the causal section. */ export const getDiagnostikBundle = async ( input: DiagnostikInput ): Promise => { if (!input.cycleId) return null; const key = `${input.cycleId}|${input.cageUuid ?? 'all'}|${input.kandangId ?? 'all'}|${input.currentDay ?? '?'}`; const cached = _bundleCache.get(key); if (cached) return cached; const promise = buildBundle(input).catch((error) => { console.warn('[insightEvidence] Gagal menyusun bukti diagnostik:', error); _bundleCache.delete(key); return null; }); _bundleCache.set(key, promise); return promise; }; /** Same bundle, re-scoped to a chosen cycle day so a report for day 20 never cites day 30. */ export const scopeDiagnostikToDay = ( bundle: DiagnostikBundle | null, day: number | null ): DiagnostikBundle | null => { if (!bundle) return null; if (day === null || !Number.isFinite(day)) return bundle; const cut = (rows: T[]) => rows.filter((row) => row.hari <= day); return { ...bundle, hari_ke: day, mortalitas_harian: cut(bundle.mortalitas_harian), bobot_harian: cut(bundle.bobot_harian), fcr_harian: cut(bundle.fcr_harian), pakan_harian: cut(bundle.pakan_harian), lingkungan_harian: cut(bundle.lingkungan_harian), }; };