318 lines
8.8 KiB
TypeScript
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 || [] }
|
|
: {}),
|
|
};
|
|
}
|