169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
import { api } from '../services/apiClient.ts';
|
||
import type { Cycle, KPI, Site } from '../types/api.ts';
|
||
|
||
export type KandangRankingRow = {
|
||
rank: number;
|
||
siteId: number;
|
||
siteName: string;
|
||
kandangId: number;
|
||
kandangName: string;
|
||
cycleId: number;
|
||
startDate: string;
|
||
endDate: string;
|
||
totalDays: number;
|
||
eef: number | null;
|
||
fcr: number | null;
|
||
};
|
||
|
||
export type LoadKandangRankingArgs = {
|
||
sites: Site[];
|
||
months: number;
|
||
/** Defaults to today (local). */
|
||
now?: Date;
|
||
};
|
||
|
||
function toIsoDate(d: Date): string {
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
}
|
||
|
||
/** Inclusive lower bound: end_date >= (today − months). */
|
||
export function rankingWindowStart(months: number, now = new Date()): string {
|
||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
start.setMonth(start.getMonth() - months);
|
||
return toIsoDate(start);
|
||
}
|
||
|
||
export function isEndDateInWindow(endDate: string | null | undefined, windowStart: string): boolean {
|
||
if (!endDate) return false;
|
||
return endDate >= windowStart;
|
||
}
|
||
|
||
/**
|
||
* Latest valid FCR/EEF for a cycle.
|
||
* Matches EefDashboardPage: sort by age/date ascending, then walk from the end.
|
||
* (API KPI list is ordered `-date` / newest-first — do not assume array order.)
|
||
*/
|
||
export function latestValidRatios(kpis: KPI[]): { eef: number | null; fcr: number | null } {
|
||
const sorted = [...kpis].sort((a, b) => a.age - b.age || a.date.localeCompare(b.date));
|
||
let eef: number | null = null;
|
||
let fcr: number | null = null;
|
||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||
const row = sorted[i];
|
||
if (!row) continue;
|
||
if (eef == null && row.eef > 0) eef = row.eef;
|
||
if (fcr == null && row.fcr > 0) fcr = row.fcr;
|
||
if (eef != null && fcr != null) break;
|
||
}
|
||
return { eef, fcr };
|
||
}
|
||
|
||
function assignRanks(
|
||
rows: Omit<KandangRankingRow, 'rank'>[]
|
||
): KandangRankingRow[] {
|
||
const withEef = rows
|
||
.filter((r) => r.eef != null)
|
||
.sort((a, b) => (b.eef as number) - (a.eef as number));
|
||
const withoutEef = rows.filter((r) => r.eef == null);
|
||
|
||
const ranked: KandangRankingRow[] = withEef.map((row, index) => ({
|
||
...row,
|
||
rank: index + 1,
|
||
}));
|
||
const unranked: KandangRankingRow[] = withoutEef.map((row) => ({
|
||
...row,
|
||
rank: 0,
|
||
}));
|
||
return [...ranked, ...unranked];
|
||
}
|
||
|
||
/**
|
||
* Load closed-cycle EEF rankings for the given sites within a months window.
|
||
* Uses one closed-cycles list + per-site kandangs + per-cycle KPI fetches.
|
||
*/
|
||
export async function loadKandangRanking({
|
||
sites,
|
||
months,
|
||
now = new Date(),
|
||
}: LoadKandangRankingArgs): Promise<KandangRankingRow[]> {
|
||
if (sites.length === 0 || months < 1) return [];
|
||
|
||
const windowStart = rankingWindowStart(months, now);
|
||
const siteById = new Map(sites.map((s) => [s.id, s]));
|
||
|
||
const kandangLists = await Promise.all(
|
||
sites.map((site) => api.kandangs.list({ site_id: site.id }))
|
||
);
|
||
|
||
type KandangMeta = { siteId: number; siteName: string; kandangName: string };
|
||
const kandangMeta = new Map<number, KandangMeta>();
|
||
kandangLists.forEach((list, i) => {
|
||
const site = sites[i];
|
||
if (!site) return;
|
||
for (const k of list) {
|
||
kandangMeta.set(k.id, {
|
||
siteId: site.id,
|
||
siteName: site.site_name,
|
||
kandangName: k.kandang_name,
|
||
});
|
||
}
|
||
});
|
||
|
||
if (kandangMeta.size === 0) return [];
|
||
|
||
let closedCycles: Cycle[] = [];
|
||
try {
|
||
closedCycles = await api.cycles.list({ status: 'closed' });
|
||
} catch {
|
||
// Fallback: per-kandang if global closed list fails
|
||
const perKandang = await Promise.all(
|
||
[...kandangMeta.keys()].map(async (kandangId) => {
|
||
try {
|
||
return await api.cycles.list({ kandang_id: kandangId, status: 'closed' });
|
||
} catch {
|
||
return [] as Cycle[];
|
||
}
|
||
})
|
||
);
|
||
closedCycles = perKandang.flat();
|
||
}
|
||
|
||
const inScope = closedCycles.filter((cycle) => {
|
||
if (!kandangMeta.has(cycle.kandang)) return false;
|
||
return isEndDateInWindow(cycle.end_date, windowStart);
|
||
});
|
||
|
||
const metricRows = await Promise.all(
|
||
inScope.map(async (cycle) => {
|
||
const meta = kandangMeta.get(cycle.kandang);
|
||
if (!meta || !cycle.end_date) return null;
|
||
// Ensure site still in requested set (defensive)
|
||
if (!siteById.has(meta.siteId)) return null;
|
||
|
||
let eef: number | null = null;
|
||
let fcr: number | null = null;
|
||
try {
|
||
const kpis = await api.kpis.list({ cycle_id: cycle.id });
|
||
({ eef, fcr } = latestValidRatios(kpis));
|
||
} catch {
|
||
// leave nulls
|
||
}
|
||
|
||
return {
|
||
siteId: meta.siteId,
|
||
siteName: meta.siteName,
|
||
kandangId: cycle.kandang,
|
||
kandangName: meta.kandangName || cycle.kandang_name,
|
||
cycleId: cycle.id,
|
||
startDate: cycle.start_date,
|
||
endDate: cycle.end_date,
|
||
totalDays: cycle.total_days || cycle.current_day || 0,
|
||
eef,
|
||
fcr,
|
||
} satisfies Omit<KandangRankingRow, 'rank'>;
|
||
})
|
||
);
|
||
|
||
return assignRanks(metricRows.filter((r): r is Omit<KandangRankingRow, 'rank'> => r != null));
|
||
}
|