Files
dashboard/utils/buildReportSnapshot.ts
T

318 lines
8.8 KiB
TypeScript

import type { FeedSackColumnConfig } from '../types/counting.ts';
import type {
DashboardReportCountingRow,
DashboardReportEefRow,
DashboardReportFcrRow,
DashboardReportFeedInRow,
DashboardReportFeedUseRow,
DashboardReportPayload,
DashboardReportType,
DashboardReportWeightRow,
} from '../types/dashboardReport.ts';
import {
countingAccuracy,
sackAccuracy,
weightAccuracy,
combinedMortality,
} from './reportAccuracy.ts';
import {
dateToReportDay,
feedOrWeightDayToReportDay,
formatLocalDate,
parseLocalDateString,
reportDayToDate,
} from './reportDay.ts';
import { getCameraNamesForKandang, getCombinedUsageCameraNames } from './feedSackMapping.ts';
export interface ReportMortalityRecord {
day: number;
mortalityCount?: number | null;
afkir?: number | null;
panen?: number | null;
beratPanen?: number | null;
chickenCount?: number | null;
}
export interface ReportChickenCountRecord {
date: string;
totalCount: number;
}
export interface ReportFeedLog {
cameraName: string;
count: number;
timestamp: Date | string;
}
export interface ReportManualFeedEntry {
cameraName: string;
count: number;
date: string;
}
export interface ReportWeightDay {
/** 1-based Berat Ayam / multi-coop day. */
day: number;
avgManual?: number | null;
avgIot?: number | null;
uniformity?: number | null;
}
export interface BuildReportSnapshotInput {
cycleId: string;
cycleStartDate: Date | string;
kandangId: number;
kandangName: string;
area: string;
upToDay: number;
initialPopulation: number;
generatedAt?: string;
cageUuid?: string | null;
mortalityRecords: ReportMortalityRecord[];
chickenCountByDay?: Record<number, number>;
chickenCounts?: ReportChickenCountRecord[];
feedSackHistory: ReportFeedLog[];
manualFeedEntries: ReportManualFeedEntry[];
feedColumnConfig: FeedSackColumnConfig[];
weightByDay?: ReportWeightDay[];
reportType?: DashboardReportType;
fcr?: DashboardReportFcrRow[];
eef?: DashboardReportEefRow[];
}
const localDateKey = (value: Date): string => formatLocalDate(value);
const parseLocalDate = (value: Date | string): Date => {
if (value instanceof Date) {
const copy = new Date(value);
copy.setHours(0, 0, 0, 0);
return copy;
}
return parseLocalDateString(value);
};
function buildChickenCountMap(
cycleStart: Date | string,
upToDay: number,
byDay?: Record<number, number>,
records?: ReportChickenCountRecord[]
): Record<number, number> {
const map: Record<number, number> = { ...(byDay || {}) };
if (!records) return map;
for (const record of records) {
const day = dateToReportDay(cycleStart, parseLocalDate(record.date));
if (day >= 0 && day <= upToDay) {
map[day] = (map[day] || 0) + (record.totalCount || 0);
}
}
return map;
}
function buildCountingRows(
input: BuildReportSnapshotInput,
cycleStart: Date | string
): DashboardReportCountingRow[] {
const chickenCountMap = buildChickenCountMap(
cycleStart,
input.upToDay,
input.chickenCountByDay,
input.chickenCounts
);
const byMortalityDay = new Map(input.mortalityRecords.map((row) => [row.day, row]));
const rows: DashboardReportCountingRow[] = [];
let previousAfter = 0;
for (let day = 0; day <= input.upToDay; day++) {
const record = byMortalityDay.get(day);
const mati = record?.mortalityCount || 0;
const afkir = record?.afkir || 0;
const harvest = record?.panen || 0;
const harvestWeight = record?.beratPanen ?? null;
const countBefore = day === 0 ? input.initialPopulation || 0 : previousAfter;
const mortality = combinedMortality(mati, afkir, harvest);
const countAfter = countBefore - mortality - harvest;
const mappedCount = chickenCountMap[day];
const recordCount = record?.chickenCount;
const iotCount: number | null =
typeof mappedCount === 'number'
? mappedCount
: typeof recordCount === 'number'
? recordCount
: null;
rows.push({
date: formatLocalDate(reportDayToDate(cycleStart, day)),
day,
countBefore,
mortality,
harvest,
harvestWeight: harvestWeight == null ? null : Number(harvestWeight),
countAfter,
iotCount,
accuracy: countingAccuracy(iotCount, countAfter),
});
previousAfter = countAfter;
}
return rows;
}
function sumFeedForDate(
cameraNames: string[],
dateKey: string,
iotLogs: ReportFeedLog[],
manualEntries: ReportManualFeedEntry[]
): { manual: number; iot: number } {
const cameras = new Set(cameraNames);
let iot = 0;
let manual = 0;
for (const log of iotLogs) {
if (!cameras.has(log.cameraName)) continue;
if (localDateKey(parseLocalDate(log.timestamp)) !== dateKey) continue;
iot += log.count || 0;
}
for (const entry of manualEntries) {
if (!cameras.has(entry.cameraName)) continue;
const entryKey = /^\d{4}-\d{2}-\d{2}/.test(entry.date)
? entry.date.slice(0, 10)
: localDateKey(parseLocalDate(entry.date));
if (entryKey !== dateKey) continue;
manual += entry.count || 0;
}
return { manual, iot };
}
function buildFeedRows(
input: BuildReportSnapshotInput,
cycleStart: Date | string
): { feedIn: DashboardReportFeedInRow[]; feedUse: DashboardReportFeedUseRow[] } {
const inboundCameras = getCameraNamesForKandang(
input.feedColumnConfig,
'inbound',
input.kandangId
);
const usageCameras = getCombinedUsageCameraNames(input.feedColumnConfig);
const feedMapped = inboundCameras.length > 0 || usageCameras.length > 0;
const feedIn: DashboardReportFeedInRow[] = [];
const feedUse: DashboardReportFeedUseRow[] = [];
for (let day = 0; day <= input.upToDay; day++) {
const date = reportDayToDate(cycleStart, day);
const dateKey = formatLocalDate(date);
if (!feedMapped) {
feedIn.push({ date: dateKey, manualIn: null, iotIn: null, accuracy: null });
feedUse.push({
date: dateKey,
day,
manualUse: null,
iotUse: null,
accuracy: null,
});
continue;
}
const inbound = sumFeedForDate(
inboundCameras,
dateKey,
input.feedSackHistory,
input.manualFeedEntries
);
const usage = sumFeedForDate(
usageCameras,
dateKey,
input.feedSackHistory,
input.manualFeedEntries
);
feedIn.push({
date: dateKey,
manualIn: inboundCameras.length ? inbound.manual : null,
iotIn: inboundCameras.length ? inbound.iot : null,
accuracy: inboundCameras.length ? sackAccuracy(inbound.manual, inbound.iot) : null,
});
feedUse.push({
date: dateKey,
day,
manualUse: usageCameras.length ? usage.manual : null,
iotUse: usageCameras.length ? usage.iot : null,
accuracy: usageCameras.length ? sackAccuracy(usage.manual, usage.iot) : null,
});
}
return { feedIn, feedUse };
}
function buildWeightRows(
input: BuildReportSnapshotInput,
cycleStart: Date | string
): DashboardReportWeightRow[] {
const rows: DashboardReportWeightRow[] = [];
const byReportDay = new Map<number, ReportWeightDay>();
if (input.cageUuid) {
for (const point of input.weightByDay || []) {
const reportDay = feedOrWeightDayToReportDay(point.day);
if (reportDay >= 0 && reportDay <= input.upToDay) {
byReportDay.set(reportDay, point);
}
}
}
for (let day = 0; day <= input.upToDay; day++) {
const date = formatLocalDate(reportDayToDate(cycleStart, day));
if (!input.cageUuid) {
rows.push({
date,
day,
avgManual: null,
avgIot: null,
uniformity: null,
difference: null,
accuracy: null,
});
continue;
}
const point = byReportDay.get(day);
const avgManual = point?.avgManual ?? null;
const avgIot = point?.avgIot ?? null;
const difference =
avgManual != null && avgIot != null ? avgManual - avgIot : null;
rows.push({
date,
day,
avgManual,
avgIot,
uniformity: point?.uniformity ?? null,
difference,
accuracy: weightAccuracy(avgManual, avgIot),
});
}
return rows;
}
export function buildReportSnapshot(input: BuildReportSnapshotInput): DashboardReportPayload {
const upToDay = Math.max(0, input.upToDay);
const cycleStart = input.cycleStartDate;
const { feedIn, feedUse } = buildFeedRows({ ...input, upToDay }, cycleStart);
const reportType = input.reportType || 'daily';
return {
cycleId: input.cycleId,
kandangId: input.kandangId,
kandangName: input.kandangName,
area: input.area,
upToDay,
generatedAt: input.generatedAt || new Date().toISOString(),
reportType,
counting: buildCountingRows({ ...input, upToDay }, cycleStart),
weight: buildWeightRows({ ...input, upToDay }, cycleStart),
feedIn,
feedUse,
...(reportType === 'end_cycle'
? { fcr: input.fcr || [], eef: input.eef || [] }
: {}),
};
}