245 lines
9.5 KiB
TypeScript
245 lines
9.5 KiB
TypeScript
/**
|
|
* 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<string, Promise<DiagnostikBundle | null>>();
|
|
|
|
const buildBundle = async (input: DiagnostikInput): Promise<DiagnostikBundle | null> => {
|
|
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<string, Record<string, unknown>> = kpiResult.status === 'fulfilled' &&
|
|
kpiResult.value &&
|
|
typeof kpiResult.value === 'object'
|
|
? (kpiResult.value as Record<string, Record<string, unknown>>)
|
|
: {};
|
|
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 = <T extends { hari: number }>(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<DiagnostikBundle | null> => {
|
|
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 = <T extends { hari: number }>(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),
|
|
};
|
|
};
|