212 lines
7.0 KiB
TypeScript
212 lines
7.0 KiB
TypeScript
import type {
|
|
ChickenCounting,
|
|
ChickenWeight,
|
|
Cycle,
|
|
Karung,
|
|
KPI,
|
|
ManualInput,
|
|
} from '../types/api.ts';
|
|
import type { DashboardReportPayload, DashboardReportType } from '../types/dashboardReport.ts';
|
|
import { cycleDayForDate } 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;
|
|
}
|
|
|
|
/** 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 cycle end date. lastDay = start_date → end_date in days, which
|
|
// is total_days - 1 for a contiguous cycle.
|
|
const lastDay = cycleDayForDate(cycle.start_date, cycle.end_date);
|
|
const endCycleUpTo = Math.min(cycle.total_days, lastDay);
|
|
// cycle.current_day is 1-based (day 1 = start_date); reports use 0-based days,
|
|
// so the latest report day is current_day - 1.
|
|
const defaultUpTo =
|
|
input.reportType === 'end_cycle'
|
|
? endCycleUpTo
|
|
: Math.min(Math.max(cycle.current_day - 1, 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<number, ChickenCounting>();
|
|
for (const row of input.countings) {
|
|
countingByDay.set(cycleDayForDate(cycle.start_date, row.date), row);
|
|
}
|
|
const weightByDay = new Map<number, ChickenWeight>();
|
|
for (const row of input.weights) {
|
|
weightByDay.set(cycleDayForDate(cycle.start_date, row.date), row);
|
|
}
|
|
const karungByDay = new Map<number, Karung>();
|
|
for (const row of input.karungs) {
|
|
karungByDay.set(cycleDayForDate(cycle.start_date, row.date), row);
|
|
}
|
|
const kpiByAge = new Map<number, KPI>();
|
|
for (const row of input.kpis) {
|
|
kpiByAge.set(row.age, row);
|
|
}
|
|
const manualWeightByDay = new Map<number, number>();
|
|
const manualFeedInByDay = new Map<number, number>();
|
|
const manualFeedUseByDay = new Map<number, number>();
|
|
const manualFeedOutByDay = new Map<number, number>();
|
|
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);
|
|
}
|
|
const latestManual = input.manuals[0] ?? null;
|
|
|
|
const counting = days.map((day) => {
|
|
const iot = countingByDay.get(day) ?? null;
|
|
const kpi = kpiByAge.get(day) ?? null;
|
|
const iotCount = iot?.total_count ?? null;
|
|
const after = kpi?.chicken_life ?? iotCount;
|
|
return {
|
|
date: addDate(cycle.start_date, day),
|
|
day,
|
|
countBefore: after == null ? null : after + (kpi?.mortality ?? iot?.mortality_count ?? 0),
|
|
mortality: kpi?.mortality ?? iot?.mortality_count ?? latestManual?.mortality_manual ?? null,
|
|
harvest: kpi?.harvest ?? null,
|
|
harvestWeight: kpi?.harvest_weight ?? null,
|
|
countAfter: after,
|
|
iotCount,
|
|
accuracy: accuracy(iotCount, after),
|
|
};
|
|
});
|
|
|
|
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,
|
|
kandangId: input.kandangId,
|
|
kandangName: input.kandangName,
|
|
area: input.area,
|
|
upToDay,
|
|
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())}`;
|
|
}
|