first commit

This commit is contained in:
Alberto-Audrix committed 2026-09-08 15:21:23 +07:00
commit b54624be96
226 files changed
+108840

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest';
import { buildInMemoryReport } from '../buildInMemoryReport.ts';
import type { ChickenCounting, Cycle, Karung, KPI } from '../../types/api.ts';
const cycle: Cycle = {
id: 1,
kandang: 9,
kandang_name: 'Kandang 1',
total_days: 35,
current_day: 1,
visible_through_date: '2026-05-02',
start_date: '2026-05-01',
end_date: '2026-06-04',
chick_in_weight: 45,
doc_in_count: 20000,
feed_initial_balance: 0,
status: 'gm',
created_at: '',
updated_at: '',
};
describe('buildInMemoryReport', () => {
it('maps IoT counting and karung onto daily rows up to current_day', () => {
const countings: ChickenCounting[] = [
{
id: 1,
cycle: 1,
kandang: 9,
flock_id: 1,
date: '2026-05-02',
total_count: 19800,
mortality_count: 12,
created_at: '',
updated_at: '',
},
];
const karungs: Karung[] = [
{
id: 1,
cycle: 1,
kandang: 9,
flock_id: 1,
date: '2026-05-02',
in_today: 10,
out_today: 8,
in_total: 10,
out_total: 8,
feed_use_today: 8,
feed_use_total: 8,
created_at: '',
updated_at: '',
},
];
const kpis: KPI[] = [
{
id: 1,
cycle: 1,
kandang: 9,
flock_id: 1,
date: '2026-05-02',
age: 1,
mortality: 12,
mortality_total: 12,
feed: 8,
feed_total: 8,
harvest: 0,
harvest_total: 0,
harvest_weight: 0,
harvest_weight_total: 0,
chicken_life: 19800,
chicken_life_percentage: 99,
iot_weight: 0,
average_harvest_day: 0,
average_harvest_weight: 0,
fcr: 1.2,
eef: 250,
cc: 1,
cw: 1,
fs: 1,
manual: 1,
created_at: '',
updated_at: '',
},
];
const payload = buildInMemoryReport({
cycle,
kandangId: 9,
kandangName: 'Kandang 1',
area: 'Sukawarna',
reportType: 'daily',
countings,
manuals: [],
weights: [],
karungs,
kpis,
});
expect(payload.upToDay).toBe(1);
expect(payload.docWeight).toBe(45);
expect(payload.counting).toHaveLength(2);
const day0 = payload.counting.find((row) => row.day === 0);
expect(day0?.countBefore).toBe(20000);
expect(day0?.countAfter).toBe(20000);
const day1 = payload.counting.find((row) => row.day === 1);
expect(day1?.countBefore).toBe(20000);
expect(day1?.iotCount).toBe(19800);
expect(day1?.mortality).toBe(12);
const feed1 = payload.feedUse.find((row) => row.day === 1);
expect(feed1?.iotUse).toBe(8);
expect(payload.fcr.find((row) => row.day === 1)?.iotFcr).toBe(1.2);
});
it('uses doc_in_count for day-0 stok awal instead of reverse-calculating from stok akhir', () => {
const countings: ChickenCounting[] = [
{
id: 1,
cycle: 1,
kandang: 9,
flock_id: 1,
date: '2026-05-01',
total_count: 36900,
mortality_count: 42,
created_at: '',
updated_at: '',
},
];
const kpis: KPI[] = [
{
id: 1,
cycle: 1,
kandang: 9,
flock_id: 1,
date: '2026-05-01',
age: 0,
mortality: 42,
mortality_total: 42,
feed: 0,
feed_total: 0,
harvest: 0,
harvest_total: 0,
harvest_weight: 0,
harvest_weight_total: 0,
chicken_life: 45000,
chicken_life_percentage: 100,
iot_weight: 0,
average_harvest_day: 0,
average_harvest_weight: 0,
fcr: 0,
eef: 0,
cc: 1,
cw: 1,
fs: 1,
manual: 1,
created_at: '',
updated_at: '',
},
];
const payload = buildInMemoryReport({
cycle: { ...cycle, doc_in_count: 45000, current_day: 0, start_date: '2026-05-01' },
kandangId: 9,
kandangName: 'Kandang 1',
area: 'Sukawarna',
reportType: 'daily',
countings,
manuals: [],
weights: [],
karungs: [],
kpis,
});
const day0 = payload.counting.find((row) => row.day === 0);
expect(day0?.countBefore).toBe(45000);
expect(day0?.mortality).toBe(42);
expect(day0?.countAfter).toBe(45000);
expect(day0?.iotCount).toBe(36900);
expect(day0?.accuracy).toBe(82);
});
});
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../../types/api.ts';
import { getCountingAccuracyWarning } from '../countingAccuracyWarning.ts';
const cycle = {
id: 1,
kandang: 1,
kandang_name: 'K1',
total_days: 35,
current_day: 5,
visible_through_date: '2026-08-15',
start_date: '2026-08-10',
end_date: '2026-09-13',
chick_in_weight: 45,
doc_in_count: 1000,
feed_initial_balance: 0,
status: 'gm',
created_at: '',
updated_at: '',
} satisfies Cycle;
function kpi(partial: Partial<KPI> & Pick<KPI, 'date' | 'chicken_life'>): KPI {
return {
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
age: 0,
mortality: 0,
mortality_total: 0,
feed: 0,
feed_total: 0,
harvest: 0,
harvest_total: 0,
harvest_weight: 0,
harvest_weight_total: 0,
chicken_life_percentage: 100,
iot_weight: 0,
average_harvest_day: 0,
average_harvest_weight: 0,
fcr: 0,
eef: 0,
cc: 0,
cw: 0,
fs: 0,
manual: 0,
created_at: '',
updated_at: '',
...partial,
};
}
function counting(partial: Partial<ChickenCounting> & Pick<ChickenCounting, 'date' | 'total_count'>): ChickenCounting {
return {
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
mortality_count: 0,
created_at: '',
updated_at: '',
...partial,
};
}
describe('getCountingAccuracyWarning', () => {
it('returns null when there is no IoT counting', () => {
expect(
getCountingAccuracyWarning({
cycle,
countings: [],
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
manualInputs: [],
})
).toBeNull();
});
it('returns under when latest IoT is below stock and accuracy <= 95', () => {
const result = getCountingAccuracyWarning({
cycle,
countings: [counting({ date: '2026-08-10', total_count: 930 })],
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
manualInputs: [],
});
expect(result).toMatchObject({
day: 0,
date: '2026-08-10',
iotCount: 930,
stockAkhir: 1000,
direction: 'under',
});
expect(result!.accuracy).toBe(93);
expect(result!.ratioPercent).toBe(93);
});
it('returns over when latest IoT is above stock and accuracy <= 95', () => {
// 1060/1000 → ratio 106%, accuracy min/max = 1000/1060 ≈ 94.3%
const result = getCountingAccuracyWarning({
cycle,
countings: [counting({ date: '2026-08-10', total_count: 1060 })],
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
manualInputs: [],
});
expect(result).toMatchObject({
day: 0,
iotCount: 1060,
stockAkhir: 1000,
direction: 'over',
});
expect(result!.accuracy).toBe(94.3);
expect(result!.ratioPercent).toBe(106);
});
it('warns when accuracy is exactly 95', () => {
const result = getCountingAccuracyWarning({
cycle,
countings: [counting({ date: '2026-08-10', total_count: 950 })],
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
manualInputs: [],
});
expect(result).toMatchObject({ accuracy: 95, direction: 'under' });
});
it('returns null when accuracy is above 95', () => {
expect(
getCountingAccuracyWarning({
cycle,
countings: [counting({ date: '2026-08-10', total_count: 960 })],
kpis: [kpi({ date: '2026-08-10', chicken_life: 1000 })],
manualInputs: [],
})
).toBeNull();
});
it('uses the latest IoT day even if an older day is worse', () => {
const result = getCountingAccuracyWarning({
cycle,
countings: [
counting({ id: 1, date: '2026-08-10', total_count: 800 }), // worse under
counting({ id: 2, date: '2026-08-12', total_count: 970 }), // latest, accuracy 97% → no warn
],
kpis: [
kpi({ date: '2026-08-10', chicken_life: 1000 }),
kpi({ date: '2026-08-11', chicken_life: 1000 }),
kpi({ date: '2026-08-12', chicken_life: 1000 }),
],
manualInputs: [],
});
expect(result).toBeNull();
});
it('warns from the latest IoT day when that day is low', () => {
const result = getCountingAccuracyWarning({
cycle,
countings: [
counting({ id: 1, date: '2026-08-10', total_count: 990 }), // fine
counting({ id: 2, date: '2026-08-12', total_count: 900 }), // latest under
],
kpis: [
kpi({ date: '2026-08-10', chicken_life: 1000 }),
kpi({ date: '2026-08-11', chicken_life: 1000 }),
kpi({ date: '2026-08-12', chicken_life: 1000 }),
],
manualInputs: [],
});
expect(result).toMatchObject({
day: 2,
date: '2026-08-12',
iotCount: 900,
direction: 'under',
});
});
it('derives stock akhir from chain when chicken_life is missing', () => {
const manuals: ManualInput[] = [
{
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-08-10',
age_manual: 0,
mortality_manual: 50,
mortality_manual_total: 50,
feed_in_manual: 0,
feed_use_manual: 0,
feed_in_manual_total: 0,
feed_use_manual_total: 0,
feed_out_manual: 0,
feed_out_manual_total: 0,
harvest_manual: 0,
harvest_manual_total: 0,
harvest_weight_manual: 0,
harvest_weight_manual_total: 0,
manual_weight: 0,
average_harvest_day_manual: 0,
average_harvest_weight_manual: 0,
fcr_manual: 0,
eef_manual: 0,
created_at: '',
updated_at: '',
},
];
const result = getCountingAccuracyWarning({
cycle,
countings: [counting({ date: '2026-08-10', total_count: 900 })],
kpis: [],
manualInputs: manuals,
});
// stock = 1000 - 50 = 950; IoT 900 → under, accuracy 900/950 ≈ 94.7
expect(result).toMatchObject({
stockAkhir: 950,
iotCount: 900,
direction: 'under',
});
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import type { KPI } from '../../types/api.ts';
import {
isEndDateInWindow,
latestValidRatios,
rankingWindowStart,
} from '../kandangRanking.ts';
function kpi(partial: Partial<KPI>): KPI {
return {
id: 1,
cycle: 1,
kandang: 1,
flock_id: 1,
date: '2026-01-01',
age: 1,
mortality: 0,
mortality_total: 0,
feed: 0,
feed_total: 0,
harvest: 0,
harvest_total: 0,
harvest_weight: 0,
harvest_weight_total: 0,
chicken_life: 1000,
chicken_life_percentage: 100,
iot_weight: 0,
average_harvest_day: 0,
average_harvest_weight: 0,
fcr: 0,
eef: 0,
cc: 0,
cw: 0,
fs: 0,
manual: 0,
created_at: '',
updated_at: '',
...partial,
};
}
describe('kandangRanking helpers', () => {
it('rankingWindowStart subtracts calendar months', () => {
expect(rankingWindowStart(3, new Date(2026, 8, 8))).toBe('2026-06-08');
expect(rankingWindowStart(1, new Date(2026, 0, 31))).toBe('2025-12-31');
});
it('isEndDateInWindow is inclusive on the lower bound', () => {
expect(isEndDateInWindow('2026-06-08', '2026-06-08')).toBe(true);
expect(isEndDateInWindow('2026-06-07', '2026-06-08')).toBe(false);
expect(isEndDateInWindow(null, '2026-06-08')).toBe(false);
});
it('latestValidRatios takes last positive EEF by age, not array order', () => {
// API-style newest-first ordering
const rows = [
kpi({ date: '2026-01-03', age: 3, eef: 310, fcr: 1.25 }),
kpi({ date: '2026-01-02', age: 2, eef: 0, fcr: 1.3 }),
kpi({ date: '2026-01-01', age: 1, eef: 200, fcr: 1.4 }),
];
expect(latestValidRatios(rows)).toEqual({ eef: 310, fcr: 1.25 });
});
it('latestValidRatios finds FCR and EEF independently from latest days', () => {
const rows = [
kpi({ date: '2026-01-03', age: 3, eef: 320, fcr: 0 }),
kpi({ date: '2026-01-02', age: 2, eef: 0, fcr: 1.22 }),
kpi({ date: '2026-01-01', age: 1, eef: 100, fcr: 1.5 }),
];
expect(latestValidRatios(rows)).toEqual({ eef: 320, fcr: 1.22 });
});
it('latestValidRatios returns null EEF when none valid', () => {
const rows = [kpi({ age: 2, eef: 0, fcr: 1.2 }), kpi({ age: 1, eef: 0, fcr: 0 })];
expect(latestValidRatios(rows)).toEqual({ eef: null, fcr: 1.2 });
});
});
+220
View File
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest';
import { buildReportSummary, latestValidRatio } from '../reportSummary.ts';
import type { ReportSummaryMetric, ReportSummaryMetricKey } from '../reportSummary.ts';
import type { DashboardReportPayload } from '../../types/dashboardReport.ts';
function emptyPayload(overrides: Partial<DashboardReportPayload> = {}): DashboardReportPayload {
return {
cycleId: '1',
cycleStart: '2026-05-01',
cycleEnd: '2026-06-04',
kandangId: 9,
kandangName: 'Kandang 1',
area: 'Area A',
upToDay: 2,
docWeight: null,
generatedAt: '2026-05-03T00:00:00.000Z',
reportType: 'daily',
counting: [],
weight: [],
feedIn: [],
feedUse: [],
feedOut: [],
fcr: [],
eef: [],
...overrides,
};
}
describe('latestValidRatio', () => {
it('returns the last finite value walking backward', () => {
expect(
latestValidRatio(
[
{ iotFcr: 1.1 },
{ iotFcr: null },
{ iotFcr: 1.4 },
{ iotFcr: null },
],
'iotFcr'
)
).toBe(1.4);
});
it('returns null when no finite values exist', () => {
expect(latestValidRatio([{ iotEef: null }, { iotEef: null }], 'iotEef')).toBeNull();
});
});
describe('buildReportSummary', () => {
it('returns 14 metrics in the agreed order with nulls when sections are empty', () => {
const summary = buildReportSummary(emptyPayload({ upToDay: 5 }));
expect(summary.metrics.map((m) => m.key)).toEqual([
'umur',
'docWeight',
'populasi',
'mortalitas',
'panen',
'beratPanen',
'karungMasuk',
'karungDituang',
'beratPakan',
'karungKeluar',
'beratAvg',
'uniformity',
'fcr',
'eef',
]);
expect(summary.metrics[0]).toMatchObject({ label: 'Umur', value: 5, unit: 'hari' });
expect(summary.metrics.slice(1).every((m) => m.value === null)).toBe(true);
});
it('derives totals, latest populasi/weight, and FCR/EEF from payload rows', () => {
const summary = buildReportSummary(
emptyPayload({
upToDay: 2,
counting: [
{
date: '2026-05-01',
day: 0,
countBefore: 20000,
mortality: 10,
harvest: 0,
harvestWeight: 0,
countAfter: 19990,
iotCount: null,
accuracy: null,
},
{
date: '2026-05-03',
day: 2,
countBefore: 19980,
mortality: 5,
harvest: 100,
harvestWeight: 150000,
countAfter: 19875,
iotCount: 19870,
accuracy: 99.9,
},
],
weight: [
{
date: '2026-05-01',
day: 0,
avgManual: 45,
avgIot: 46,
uniformity: 80,
difference: 1,
accuracy: 97,
},
{
date: '2026-05-03',
day: 2,
avgManual: 900,
avgIot: 910,
uniformity: 88.5,
difference: 10,
accuracy: 98,
},
],
feedIn: [
{ date: '2026-05-01', manualIn: 20, iotIn: 21, accuracy: 95 },
{ date: '2026-05-03', manualIn: 10, iotIn: 10, accuracy: 100 },
],
feedUse: [
{ date: '2026-05-01', day: 0, manualUse: 8, iotUse: 8, accuracy: 100 },
{ date: '2026-05-03', day: 2, manualUse: 12, iotUse: 11, accuracy: 91 },
],
feedOut: [
{ date: '2026-05-01', manualOut: 1, iotOut: 1, accuracy: 100 },
{ date: '2026-05-03', manualOut: 2, iotOut: 2, accuracy: 100 },
],
fcr: [
{
day: 0,
iotFcr: 0.5,
feedTotal: 1,
iotWeight: 46,
chickenLife: 19990,
harvestWeightTotal: 0,
},
{
day: 2,
iotFcr: 1.25,
feedTotal: 20,
iotWeight: 910,
chickenLife: 19875,
harvestWeightTotal: 150,
},
],
eef: [
{
day: 0,
iotEef: 100,
chickenLifePercentage: 99.9,
iotAverageHarvestBw: null,
iotAverageDayWeight: 46,
iotFcr: 0.5,
},
{
day: 2,
iotEef: 320,
chickenLifePercentage: 99.4,
iotAverageHarvestBw: 1500,
iotAverageDayWeight: 910,
iotFcr: 1.25,
},
],
})
);
const byKey = Object.fromEntries(summary.metrics.map((m) => [m.key, m])) as Record<
ReportSummaryMetricKey,
ReportSummaryMetric
>;
expect(byKey.umur.value).toBe(2);
expect(byKey.docWeight.value).toBeNull();
expect(byKey.populasi.value).toBe(19875);
expect(byKey.mortalitas.value).toBe(15);
expect(byKey.panen.value).toBe(100);
expect(byKey.beratPanen.value).toBe(150);
expect(byKey.karungMasuk.value).toBe(30);
expect(byKey.karungDituang.value).toBe(20);
expect(byKey.beratPakan.value).toBe(1000);
expect(byKey.karungKeluar.value).toBe(3);
expect(byKey.beratAvg.value).toBe(910);
expect(byKey.uniformity.value).toBe(88.5);
expect(byKey.fcr.value).toBe(1.25);
expect(byKey.eef.value).toBe(320);
});
it('falls back to avgManual when avgIot is null on the latest weight row', () => {
const summary = buildReportSummary(
emptyPayload({
weight: [
{
date: '2026-05-03',
day: 2,
avgManual: 905,
avgIot: null,
uniformity: 90,
difference: null,
accuracy: null,
},
],
})
);
expect(summary.metrics.find((m) => m.key === 'beratAvg')?.value).toBe(905);
});
it('places Berat DOC beside Umur from payload.docWeight', () => {
const summary = buildReportSummary(emptyPayload({ upToDay: 3, docWeight: 42 }));
expect(summary.metrics[0]).toMatchObject({ key: 'umur', value: 3 });
expect(summary.metrics[1]).toMatchObject({
key: 'docWeight',
label: 'Berat DOC',
value: 42,
unit: 'gram',
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { isOnOrBeforeVisibleDate, throughVisibleDate } from '../visibility.ts';
describe('throughVisibleDate', () => {
const rows = [{ date: '2026-09-05' }, { date: '2026-09-06' }, { date: '2026-09-07' }];
it('drops rows after the publish cutoff', () => {
expect(throughVisibleDate(rows, '2026-09-06')).toEqual([
{ date: '2026-09-05' },
{ date: '2026-09-06' },
]);
});
it('returns all rows when cutoff is missing', () => {
expect(throughVisibleDate(rows, undefined)).toEqual(rows);
expect(throughVisibleDate(rows, null)).toEqual(rows);
});
});
describe('isOnOrBeforeVisibleDate', () => {
it('rejects dates after the cutoff', () => {
expect(isOnOrBeforeVisibleDate('2026-09-07', '2026-09-06')).toBe(false);
});
it('accepts dates on or before the cutoff', () => {
expect(isOnOrBeforeVisibleDate('2026-09-06', '2026-09-06')).toBe(true);
expect(isOnOrBeforeVisibleDate('2026-09-05', '2026-09-06')).toBe(true);
});
it('rejects empty dates', () => {
expect(isOnOrBeforeVisibleDate(null, '2026-09-06')).toBe(false);
expect(isOnOrBeforeVisibleDate(undefined, '2026-09-06')).toBe(false);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { weightStandardData } from '../../mockData/weightStandardData.ts';
import { getTargetGainForAge, getTargetWeightForAge } from '../weight.ts';
function rowForDoc(docWeight: number) {
return weightStandardData.data.find((row) => row.day === docWeight)!;
}
describe('getTargetWeightForAge', () => {
it('uses the exact DOC-weight row and age column', () => {
const row = rowForDoc(42);
expect(getTargetWeightForAge(1, 42)).toBe(row.values[0]);
expect(getTargetWeightForAge(35, 42)).toBe(row.values[34]);
});
it('clamps below-range DOC weight to the 35 g row', () => {
const row = rowForDoc(35);
expect(getTargetWeightForAge(7, 20)).toBe(row.values[6]);
expect(getTargetWeightForAge(7, 30)).toBe(row.values[6]);
});
it('clamps above-range DOC weight to the 51 g row', () => {
const row = rowForDoc(51);
expect(getTargetWeightForAge(7, 52)).toBe(row.values[6]);
expect(getTargetWeightForAge(7, 60)).toBe(row.values[6]);
});
it('returns null for ages outside 1..35', () => {
expect(getTargetWeightForAge(0, 42)).toBeNull();
expect(getTargetWeightForAge(36, 42)).toBeNull();
});
});
describe('getTargetGainForAge', () => {
it('returns day-over-day delta from the DOC-weight row', () => {
const row = rowForDoc(40);
expect(getTargetGainForAge(2, 40)).toBe(row.values[1]! - row.values[0]!);
});
it('returns null for age 1', () => {
expect(getTargetGainForAge(1, 40)).toBeNull();
});
});
+231
View File
@@ -0,0 +1,231 @@
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;
}
function countingAccuracy(iot: number | null, stockAkhir: number | null): number | null {
if (iot == null || stockAkhir == null || stockAkhir <= 0) return null;
return Math.round((Math.min(iot, stockAkhir) / Math.max(iot, stockAkhir)) * 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);
const defaultUpTo =
input.reportType === 'end_cycle'
? endCycleUpTo
: Math.min(Math.max(cycle.current_day, 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 manualByDay = new Map<number, ManualInput>();
for (const row of input.manuals) {
manualByDay.set(cycleDayForDate(cycle.start_date, row.date), 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);
}
let previousStockAkhir: number | null = null;
const counting = days.map((day) => {
const iot = countingByDay.get(day) ?? null;
const kpi = kpiByAge.get(day) ?? null;
const manual = manualByDay.get(day) ?? null;
const iotCount = iot?.total_count ?? null;
const mortality =
manual?.mortality_manual ?? kpi?.mortality ?? iot?.mortality_count ?? null;
const harvest = manual?.harvest_manual ?? kpi?.harvest ?? null;
const harvestWeight = manual?.harvest_weight_manual ?? kpi?.harvest_weight ?? null;
const mati = mortality ?? 0;
const panen = harvest ?? 0;
const countBefore = day === 0 ? cycle.doc_in_count || 0 : (previousStockAkhir ?? 0);
const countAfter = kpi?.chicken_life ?? Math.max(0, countBefore - mati - panen);
previousStockAkhir = countAfter;
return {
date: addDate(cycle.start_date, day),
day,
countBefore,
mortality,
harvest,
harvestWeight,
countAfter,
iotCount,
accuracy: countingAccuracy(iotCount, countAfter),
};
});
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,
docWeight:
typeof cycle.chick_in_weight === 'number' && Number.isFinite(cycle.chick_in_weight)
? cycle.chick_in_weight
: null,
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())}`;
}
+96
View File
@@ -0,0 +1,96 @@
import type { ChickenCounting, Cycle, KPI, ManualInput } from '../types/api.ts';
import { addDaysIso, cycleDayForDate } from './format.ts';
export const COUNTING_ACCURACY_WARNING_THRESHOLD = 95;
export type CountingAccuracyWarning = {
day: number;
date: string;
iotCount: number;
stockAkhir: number;
accuracy: number;
ratioPercent: number;
direction: 'over' | 'under';
};
export type CountingAccuracyWarningInput = {
cycle: Pick<Cycle, 'start_date' | 'end_date' | 'total_days' | 'doc_in_count'>;
countings: ChickenCounting[];
kpis: KPI[];
manualInputs: ManualInput[];
};
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
function matchAccuracy(iot: number, stockAkhir: number): number {
return round1((Math.min(iot, stockAkhir) / Math.max(iot, stockAkhir)) * 100);
}
/** Build stock-akhir by date using the same rules as Hitung Ayam. */
function stockAkhirByDate(
cycle: CountingAccuracyWarningInput['cycle'],
kpis: KPI[],
manualInputs: ManualInput[]
): Map<string, number> {
const kpiByDate = new Map(kpis.map((k) => [k.date, k]));
const manualByDate = new Map(manualInputs.map((m) => [m.date, m]));
const lastCycleDay = Math.max(0, (cycle.total_days || 1) - 1);
const endDay = cycleDayForDate(cycle.start_date, cycle.end_date);
const maxDay = Math.min(lastCycleDay, endDay);
const result = new Map<string, number>();
let previousStockAkhir = 0;
for (let day = 0; day <= maxDay; day++) {
const date = addDaysIso(cycle.start_date, day);
const kpi = kpiByDate.get(date);
const manual = manualByDate.get(date);
const stockAwal = day === 0 ? cycle.doc_in_count || 0 : previousStockAkhir;
const mati = manual?.mortality_manual ?? kpi?.mortality ?? 0;
const panen = manual?.harvest_manual ?? kpi?.harvest ?? 0;
const stockAkhir = kpi?.chicken_life ?? Math.max(0, stockAwal - mati - panen);
result.set(date, stockAkhir);
previousStockAkhir = stockAkhir;
}
return result;
}
function latestIotCounting(countings: ChickenCounting[]): ChickenCounting | null {
let best: ChickenCounting | null = null;
for (const row of countings) {
if (row.total_count == null) continue;
if (!best || row.date > best.date) best = row;
}
return best;
}
export function getCountingAccuracyWarning(
input: CountingAccuracyWarningInput
): CountingAccuracyWarning | null {
const latest = latestIotCounting(input.countings);
if (!latest) return null;
const stockMap = stockAkhirByDate(input.cycle, input.kpis, input.manualInputs);
const stockAkhir = stockMap.get(latest.date);
if (stockAkhir == null || stockAkhir <= 0) return null;
const iotCount = latest.total_count;
if (iotCount === stockAkhir) return null;
const accuracy = matchAccuracy(iotCount, stockAkhir);
if (accuracy > COUNTING_ACCURACY_WARNING_THRESHOLD) return null;
return {
day: cycleDayForDate(input.cycle.start_date, latest.date),
date: latest.date,
iotCount,
stockAkhir,
accuracy,
ratioPercent: round1((iotCount / stockAkhir) * 100),
direction: iotCount > stockAkhir ? 'over' : 'under',
};
}
+74
View File
@@ -0,0 +1,74 @@
export function formatNumber(
value: number,
maxDecimals = 2,
minDecimals = 0,
truncate = false
): string {
let display = value;
if (truncate) {
const factor = 10 ** maxDecimals;
display = Math.trunc(display * factor) / factor;
}
return display.toLocaleString('id-ID', {
maximumFractionDigits: maxDecimals,
minimumFractionDigits: minDecimals,
});
}
export function formatRatio(value: number, decimals = 3): string {
const factor = 10 ** decimals;
const truncated = Math.trunc(value * factor) / factor;
return truncated.toFixed(decimals);
}
export function formatDateId(iso: string | null | undefined): string {
if (!iso) return '—';
const date = new Date(`${iso}T00:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
}
export function formatDateRange(startIso: string, endIso?: string | null): string {
if (!endIso) return formatDateId(startIso);
return `${formatDateId(startIso)} – ${formatDateId(endIso)}`;
}
export function todayIso(): string {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
export function addDaysIso(iso: string, days: number): string {
const date = new Date(`${iso}T00:00:00`);
date.setDate(date.getDate() + days);
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export function cycleDayForDate(startDate: string, isoDate: string): number {
const start = new Date(`${startDate}T00:00:00`);
const current = new Date(`${isoDate}T00:00:00`);
const diff = Math.round((current.getTime() - start.getTime()) / 86400000);
return Math.max(0, diff);
}
export function inclusiveDayCount(startIso: string, endIso: string): number {
return Math.max(1, cycleDayForDate(startIso, endIso) + 1);
}
export type CycleStatusUi = 'active' | 'pending_close' | 'closed';
export function resolveCycleStatus(cycle: {
status?: string;
end_date?: string | null;
visible_through_date?: string;
}): CycleStatusUi {
if (cycle.status === 'pending_close' || cycle.status === 'closed' || cycle.status === 'active') {
return cycle.status;
}
if (cycle.end_date && cycle.visible_through_date && cycle.visible_through_date >= cycle.end_date) {
return 'closed';
}
return 'active';
}
+168
View File
@@ -0,0 +1,168 @@
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));
}
+131
View File
@@ -0,0 +1,131 @@
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
const PAGE_WIDTH_MM = 210;
const PAGE_HEIGHT_MM = 297;
const MARGIN_MM = 10;
const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - MARGIN_MM * 2;
const USABLE_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_MM * 2;
export const usableHeightCssPx = (widthPx: number): number =>
(USABLE_HEIGHT_MM / CONTENT_WIDTH_MM) * widthPx;
/**
* Safe page-break offsets for `data-pdf-block` nodes.
*
* A block's start is only a break point when the whole block fits on one page.
* Oversized tables (e.g. a 49-day report) must start under the header instead
* of being deferred to page 2.
*/
export function pdfBlockBreakOffsets(
blocks: Array<{ top: number; bottom: number }>,
usableHeightPx: number
): number[] {
const breaks: number[] = [];
for (const block of blocks) {
const height = block.bottom - block.top;
if (height <= usableHeightPx && block.top > 0) {
breaks.push(block.top);
}
if (block.bottom > 0) {
breaks.push(block.bottom);
}
}
return [...new Set(breaks.map((value) => Math.round(value)))].sort((a, b) => a - b);
}
export function choosePdfSliceHeight(
cursor: number,
canvasHeight: number,
usableHeightPx: number,
breakPoints: number[]
): number {
const remaining = canvasHeight - cursor;
if (remaining <= usableHeightPx) return remaining;
const limit = cursor + usableHeightPx;
const candidate = [...breakPoints].reverse().find((point) => point > cursor && point <= limit);
return candidate ? candidate - cursor : usableHeightPx;
}
/**
* Render an element to a multi-page A4 PDF.
*
* html2canvas photographs the whole report once, then the canvas is sliced at
* `data-pdf-block` boundaries (so a page break never cuts through a table) and
* each slice is written as a compressed JPEG page.
*/
export async function exportElementToPdf(
element: HTMLElement,
filename: string,
options?: { widthPx?: number }
): Promise<void> {
const widthPx = options?.widthPx ?? 800;
const scale = 1.5;
const jpegQuality = 0.82;
const blockRects: Array<{ top: number; bottom: number }> = [];
const canvas = await html2canvas(element, {
scale,
useCORS: true,
backgroundColor: '#ffffff',
windowWidth: widthPx,
onclone: (_clonedDoc, clonedElement) => {
clonedElement.style.width = `${widthPx}px`;
clonedElement.style.maxWidth = `${widthPx}px`;
clonedElement.style.margin = '0';
const top = clonedElement.getBoundingClientRect().top;
clonedElement.querySelectorAll<HTMLElement>('[data-pdf-block]').forEach((node) => {
const rect = node.getBoundingClientRect();
blockRects.push({ top: rect.top - top, bottom: rect.bottom - top });
});
},
});
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true });
const pageWidthMm = doc.internal.pageSize.getWidth();
const pageHeightMm = doc.internal.pageSize.getHeight();
const contentWidthMm = pageWidthMm - MARGIN_MM * 2;
const usableHeightMm = pageHeightMm - MARGIN_MM * 2;
const mmPerPx = contentWidthMm / canvas.width;
const usableHeightPx = usableHeightMm / mmPerPx;
const safeBreaks = pdfBlockBreakOffsets(blockRects, usableHeightCssPx(widthPx));
// Clone-CSS px -> canvas px, de-duplicated and ordered.
const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))]
.filter((value) => value > 0 && value < canvas.height)
.sort((a, b) => a - b);
let cursor = 0;
let firstPage = true;
while (cursor < canvas.height) {
const sliceHeight = choosePdfSliceHeight(cursor, canvas.height, usableHeightPx, breakPoints);
if (sliceHeight <= 0) break;
const slice = document.createElement('canvas');
slice.width = canvas.width;
slice.height = Math.max(1, Math.round(sliceHeight));
const ctx = slice.getContext('2d');
if (!ctx) break;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, slice.width, slice.height);
ctx.drawImage(canvas, 0, cursor, canvas.width, slice.height, 0, 0, canvas.width, slice.height);
if (!firstPage) doc.addPage();
doc.addImage(
slice.toDataURL('image/jpeg', jpegQuality),
'JPEG',
MARGIN_MM,
MARGIN_MM,
contentWidthMm,
slice.height * mmPerPx
);
firstPage = false;
cursor += slice.height;
}
doc.save(filename);
}
+467
View File
@@ -0,0 +1,467 @@
import ExcelJS from 'exceljs';
import type { DashboardReportPayload } from '../types/dashboardReport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from './reportTotals.ts';
import { formatDateId, formatDateRange, formatNumber, formatRatio } from './format.ts';
import { buildReportSummary, formatSummaryValue } from './reportSummary.ts';
const COLORS = {
headerFill: 'FFF8FAFC',
headerFont: 'FF475569',
accentFill: 'FF6D28D0',
accentDeep: 'FF5B21B6',
accentFont: 'FFFFFFFF',
totalFill: 'FFF1F5F9',
zebraFill: 'FFFAFAFA',
border: 'FFE2E8F0',
titleFont: 'FF111827',
metaFont: 'FF374151',
};
const thinBorder: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: COLORS.border } },
left: { style: 'thin', color: { argb: COLORS.border } },
bottom: { style: 'thin', color: { argb: COLORS.border } },
right: { style: 'thin', color: { argb: COLORS.border } },
};
type CellValue = string | number | null;
const isPresent = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value);
const dash = (value: number | null | undefined): CellValue =>
isPresent(value) ? value : '-';
const na = (value: number | null | undefined): CellValue =>
isPresent(value) ? value : 'N/A';
const pct = (value: number | null | undefined): CellValue =>
isPresent(value) ? `${value.toFixed(1)}%` : 'N/A';
const qty = (value: number | null | undefined, maxDecimals = 0, suffix = ''): CellValue => {
if (!isPresent(value)) return 'N/A';
return `${formatNumber(value, maxDecimals, 0, true)}${suffix}`;
};
const ratio = (value: number | null | undefined): string =>
isPresent(value) ? formatRatio(value, 3) : 'N/A';
const harvestKg = (grams: number | null | undefined): CellValue => {
if (!isPresent(grams)) return 'N/A';
return `${formatNumber(grams / 1000, 1)} kg`;
};
function styleHeaderRow(row: ExcelJS.Row, colCount: number): void {
row.height = 22;
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.font = { bold: true, color: { argb: COLORS.headerFont }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.headerFill } };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = thinBorder;
}
}
function styleDataRow(row: ExcelJS.Row, colCount: number, stripe: boolean): void {
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.font = { size: 11, color: { argb: COLORS.titleFont } };
cell.alignment = {
vertical: 'middle',
horizontal: c === 1 ? 'left' : 'right',
};
if (stripe) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.zebraFill } };
}
cell.border = thinBorder;
}
}
function styleTotalRow(row: ExcelJS.Row, colCount: number): void {
row.height = 22;
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.font = { bold: true, size: 11, color: { argb: COLORS.titleFont } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.totalFill } };
cell.alignment = {
vertical: 'middle',
horizontal: c === 1 ? 'left' : 'right',
};
cell.border = {
...thinBorder,
top: { style: 'medium', color: { argb: COLORS.border } },
};
}
}
function styleHighlightKpiRow(row: ExcelJS.Row, colCount: number): void {
row.height = 28;
const highlightBorder: Partial<ExcelJS.Borders> = {
top: { style: 'medium', color: { argb: COLORS.accentDeep } },
left: { style: 'medium', color: { argb: COLORS.accentDeep } },
bottom: { style: 'medium', color: { argb: COLORS.accentDeep } },
right: { style: 'medium', color: { argb: COLORS.accentDeep } },
};
for (let c = 1; c <= colCount; c += 1) {
const cell = row.getCell(c);
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.accentFill } };
cell.font = {
bold: true,
size: c === 2 ? 14 : 11,
color: { argb: COLORS.accentFont },
};
cell.alignment = {
vertical: 'middle',
horizontal: c === 2 ? 'center' : 'left',
};
cell.border = highlightBorder;
}
}
function autoFitColumns(sheet: ExcelJS.Worksheet, min = 10, max = 42): void {
sheet.columns.forEach((column) => {
let maxLen = min;
column.eachCell?.({ includeEmpty: false }, (cell) => {
const text =
cell.value instanceof Date
? cell.value.toLocaleDateString('id-ID')
: String(cell.value ?? '');
maxLen = Math.max(maxLen, text.length);
});
column.width = Math.min(max, Math.max(min, maxLen + 2));
});
}
function addSheetTitle(sheet: ExcelJS.Worksheet, title: string, colCount: number): void {
sheet.mergeCells(1, 1, 1, colCount);
const cell = sheet.getCell(1, 1);
cell.value = title;
cell.font = { bold: true, size: 13, color: { argb: COLORS.titleFont } };
cell.alignment = { vertical: 'middle', horizontal: 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: COLORS.headerFill } };
cell.border = {
...thinBorder,
bottom: { style: 'medium', color: { argb: COLORS.accentFill } },
};
sheet.getRow(1).height = 26;
}
function addTable(
sheet: ExcelJS.Worksheet,
title: string,
headers: string[],
rows: CellValue[][],
totalRow?: CellValue[] | null
): void {
const colCount = headers.length;
addSheetTitle(sheet, title, colCount);
const headerRow = sheet.addRow(headers);
styleHeaderRow(headerRow, colCount);
rows.forEach((values, index) => {
const row = sheet.addRow(values);
styleDataRow(row, colCount, index % 2 === 1);
});
if (totalRow) {
const row = sheet.addRow(totalRow);
styleTotalRow(row, colCount);
}
sheet.views = [{ state: 'frozen', ySplit: 2, activeCell: 'A3' }];
autoFitColumns(sheet);
}
function addSummarySheet(workbook: ExcelJS.Workbook, payload: DashboardReportPayload): void {
const isEndCycle = payload.reportType === 'end_cycle';
const title = isEndCycle ? 'Laporan Akhir Siklus' : 'Laporan Harian';
const lastDate = payload.counting[payload.counting.length - 1]?.date ?? payload.cycleStart;
const printedDate = new Date(payload.generatedAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const sheet = workbook.addWorksheet('Ringkasan');
sheet.getColumn(1).width = 22;
sheet.getColumn(2).width = 48;
const addMeta = (label: string, value: string | number, bold = false): void => {
const row = sheet.addRow([label, value]);
row.getCell(1).font = { bold: true, size: 11, color: { argb: COLORS.metaFont } };
row.getCell(2).font = {
bold,
size: bold ? 14 : 11,
color: { argb: COLORS.titleFont },
};
row.height = bold ? 24 : 20;
};
sheet.getCell('A1').value = 'Dashboard CPSP';
sheet.getCell('A1').font = { bold: true, size: 10, color: { argb: COLORS.accentFill } };
sheet.addRow([]);
addMeta('Judul', title, true);
addMeta('Lokasi', `${payload.area} — ${payload.kandangName}`);
addMeta(
'Periode',
`Hari ke-0 s/d ke-${payload.upToDay} (${formatDateId(payload.cycleStart)} - ${formatDateId(lastDate)})`
);
addMeta('Siklus', formatDateRange(payload.cycleStart, payload.cycleEnd));
addMeta(
'Berat DOC',
payload.docWeight == null || !Number.isFinite(payload.docWeight)
? '—'
: `${formatNumber(payload.docWeight, 1, 0, true)} gram`
);
addMeta('Dicetak', printedDate);
sheet.addRow([]);
const kpiTitle = sheet.addRow(['Ringkasan KPI']);
kpiTitle.getCell(1).font = {
bold: true,
size: 12,
color: { argb: COLORS.accentFill },
};
sheet.getColumn(3).width = 12;
const kpiHeader = sheet.addRow(['Metrik', 'Nilai', 'Satuan']);
styleHeaderRow(kpiHeader, 3);
const summary = buildReportSummary(payload);
let insertedHighlightGap = false;
summary.metrics.forEach((metric, index) => {
const isHighlight = metric.key === 'fcr' || metric.key === 'eef';
if (isHighlight && !insertedHighlightGap) {
sheet.addRow([]);
insertedHighlightGap = true;
}
const row = sheet.addRow([
metric.label,
formatSummaryValue(metric),
metric.unit || '—',
]);
if (isHighlight) {
styleHighlightKpiRow(row, 3);
} else {
styleDataRow(row, 3, index % 2 === 1);
row.getCell(1).alignment = { vertical: 'middle', horizontal: 'left' };
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
row.getCell(3).alignment = { vertical: 'middle', horizontal: 'left' };
}
});
sheet.addRow([]);
const navHeader = sheet.addRow(['Sheet', 'Isi']);
styleHeaderRow(navHeader, 2);
const navRows: Array<[string, string]> = [
['Hitung Ayam', 'Stok, mortalitas, panen, dan akurasi hitung harian'],
['Berat Ayam', 'Rata-rata berat manual vs IoT dan akurasi'],
['Karung Masuk', 'Pakan masuk manual vs IoT'],
['Karung Dituang', 'Pakan dituang manual vs IoT'],
['Karung Keluar', 'Pakan keluar manual vs IoT'],
];
if (isEndCycle) {
navRows.push(['FCR', 'Data pendukung FCR per umur']);
navRows.push(['EEF', 'Data pendukung EEF per umur']);
}
navRows.forEach(([name, desc], index) => {
const row = sheet.addRow([name, desc]);
styleDataRow(row, 2, index % 2 === 1);
});
}
export async function downloadReportExcel(payload: DashboardReportPayload): Promise<void> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Dashboard CPSP';
workbook.created = new Date();
addSummarySheet(workbook, payload);
const countingTotal = countingTotals(payload.counting);
addTable(
workbook.addWorksheet('Hitung Ayam'),
'Hitung Ayam harian',
[
'Tanggal',
'Umur (hari)',
'Stok awal',
'Mortalitas',
'Panen',
'Berat panen',
'Stok akhir',
'IoT',
'Akurasi %',
],
payload.counting.map((row) => [
formatDateId(row.date),
row.day,
na(row.countBefore),
na(row.mortality),
na(row.harvest),
na(row.harvestWeight),
na(row.countAfter),
na(row.iotCount),
pct(row.accuracy),
]),
countingTotal
? [
countingTotal.label,
'—',
dash(countingTotal.countBefore),
na(countingTotal.mortality),
na(countingTotal.harvest),
na(countingTotal.harvestWeight),
dash(countingTotal.countAfter),
dash(countingTotal.iotCount),
countingTotal.accuracy == null ? '-' : pct(countingTotal.accuracy),
]
: null
);
addTable(
workbook.addWorksheet('Berat Ayam'),
'Berat Ayam harian',
[
'Tanggal',
'Umur (hari)',
'Rata-rata manual',
'Rata-rata IoT',
'Keseragaman',
'Selisih',
'Akurasi %',
],
payload.weight.map((row) => [
formatDateId(row.date),
row.day,
na(row.avgManual),
na(row.avgIot),
pct(row.uniformity),
na(row.difference),
pct(row.accuracy),
])
);
const feedInTotal = feedInTotals(payload.feedIn);
addTable(
workbook.addWorksheet('Karung Masuk'),
'Karung masuk',
['Tanggal', 'Manual masuk', 'IoT masuk', 'Akurasi %'],
payload.feedIn.map((row) => [
formatDateId(row.date),
na(row.manualIn),
na(row.iotIn),
pct(row.accuracy),
]),
feedInTotal
? [
feedInTotal.label,
na(feedInTotal.manualIn),
na(feedInTotal.iotIn),
pct(feedInTotal.accuracy),
]
: null
);
const feedUseTotal = feedUseTotals(payload.feedUse);
addTable(
workbook.addWorksheet('Karung Dituang'),
'Karung dituang',
['Tanggal', 'Umur (hari)', 'Manual dituang', 'IoT dituang', 'Akurasi %'],
payload.feedUse.map((row) => [
formatDateId(row.date),
row.day,
na(row.manualUse),
na(row.iotUse),
pct(row.accuracy),
]),
feedUseTotal
? [
feedUseTotal.label,
'—',
na(feedUseTotal.manualUse),
na(feedUseTotal.iotUse),
pct(feedUseTotal.accuracy),
]
: null
);
const feedOutTotal = feedOutTotals(payload.feedOut);
addTable(
workbook.addWorksheet('Karung Keluar'),
'Karung keluar',
['Tanggal', 'Manual keluar', 'IoT keluar', 'Akurasi %'],
payload.feedOut.map((row) => [
formatDateId(row.date),
na(row.manualOut),
na(row.iotOut),
pct(row.accuracy),
]),
feedOutTotal
? [
feedOutTotal.label,
na(feedOutTotal.manualOut),
na(feedOutTotal.iotOut),
pct(feedOutTotal.accuracy),
]
: null
);
if (payload.reportType === 'end_cycle') {
addTable(
workbook.addWorksheet('FCR'),
'Data Pendukung FCR',
['Umur', 'Total Pakan', 'Bobot IoT', 'Ayam Hidup', 'Total Bobot Panen', 'FCR'],
payload.fcr.map((row) => [
`Hari ${row.day}`,
qty(row.feedTotal, 0, ' karung'),
qty(row.iotWeight, 2, row.iotWeight != null ? ' g' : ''),
qty(row.chickenLife, 0, ' ekor'),
harvestKg(row.harvestWeightTotal),
ratio(row.iotFcr),
])
);
addTable(
workbook.addWorksheet('EEF'),
'Data Pendukung EEF',
[
'Umur',
'Persentase Ayam Hidup',
'BW Rata-rata Panen',
'Umur Rata-rata Panen',
'FCR',
'EEF',
],
payload.eef.map((row) => [
`Hari ${row.day}`,
qty(row.chickenLifePercentage, 3, '%'),
qty(row.iotAverageHarvestBw, 2, row.iotAverageHarvestBw != null ? ' g' : ''),
qty(row.iotAverageDayWeight, 2),
ratio(row.iotFcr),
ratio(row.iotEef),
])
);
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const period =
payload.reportType === 'end_cycle' ? 'Akhir-Siklus' : `Harian-Hari-${payload.upToDay}`;
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `Laporan-${period}-${payload.area}-${payload.kandangName}-${payload.cycleStart}_sd_${payload.cycleEnd}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
}
+166
View File
@@ -0,0 +1,166 @@
import type { DashboardReportPayload } from '../types/dashboardReport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from './reportTotals.ts';
import { formatNumber, formatRatio } from './format.ts';
export type ReportSummaryMetricKey =
| 'umur'
| 'docWeight'
| 'populasi'
| 'mortalitas'
| 'panen'
| 'beratPanen'
| 'karungMasuk'
| 'karungDituang'
| 'beratPakan'
| 'karungKeluar'
| 'beratAvg'
| 'uniformity'
| 'fcr'
| 'eef';
export interface ReportSummaryMetric {
key: ReportSummaryMetricKey;
label: string;
value: number | null;
unit: string;
}
export interface ReportSummary {
metrics: ReportSummaryMetric[];
}
const isPresent = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value);
/** kg per karung — same factor as Dashboard feed weight panel */
const FEED_SACK_KG = 50;
export function latestValidRatio(
rows: Array<{ iotFcr?: number | null; iotEef?: number | null }>,
key: 'iotFcr' | 'iotEef'
): number | null {
for (let i = rows.length - 1; i >= 0; i -= 1) {
const value = rows[i]?.[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return null;
}
export function buildReportSummary(payload: DashboardReportPayload): ReportSummary {
const lastCounting = payload.counting[payload.counting.length - 1] ?? null;
const lastWeight = payload.weight[payload.weight.length - 1] ?? null;
const counting = countingTotals(payload.counting);
const feedIn = feedInTotals(payload.feedIn);
const feedUse = feedUseTotals(payload.feedUse);
const feedOut = feedOutTotals(payload.feedOut);
const beratAvg = lastWeight
? isPresent(lastWeight.avgIot)
? lastWeight.avgIot
: isPresent(lastWeight.avgManual)
? lastWeight.avgManual
: null
: null;
const metrics: ReportSummaryMetric[] = [
{ key: 'umur', label: 'Umur', value: payload.upToDay, unit: 'hari' },
{
key: 'docWeight',
label: 'Berat DOC',
value: isPresent(payload.docWeight) ? payload.docWeight : null,
unit: 'gram',
},
{
key: 'populasi',
label: 'Populasi',
value: isPresent(lastCounting?.countAfter) ? lastCounting!.countAfter : null,
unit: 'ekor',
},
{
key: 'mortalitas',
label: 'Mortalitas',
value: counting?.mortality ?? null,
unit: 'ekor',
},
{
key: 'panen',
label: 'Panen',
value: counting?.harvest ?? null,
unit: 'ekor',
},
{
key: 'beratPanen',
label: 'Berat Panen',
// counting harvestWeight is stored in grams; surface kg for the summary card
value: isPresent(counting?.harvestWeight) ? counting!.harvestWeight / 1000 : null,
unit: 'kg',
},
{
key: 'karungMasuk',
label: 'Karung Masuk',
value: feedIn?.manualIn ?? null,
unit: 'karung',
},
{
key: 'karungDituang',
label: 'Karung Dituang',
value: feedUse?.manualUse ?? null,
unit: 'karung',
},
{
key: 'beratPakan',
label: 'Berat Pakan',
value: isPresent(feedUse?.manualUse) ? feedUse!.manualUse * FEED_SACK_KG : null,
unit: 'kg',
},
{
key: 'karungKeluar',
label: 'Karung Keluar',
value: feedOut?.manualOut ?? null,
unit: 'karung',
},
{ key: 'beratAvg', label: 'Berat AVG', value: beratAvg, unit: 'gram' },
{
key: 'uniformity',
label: 'Uniformity',
value: isPresent(lastWeight?.uniformity) ? lastWeight!.uniformity : null,
unit: '%',
},
{
key: 'fcr',
label: 'FCR',
value: latestValidRatio(payload.fcr, 'iotFcr'),
unit: '',
},
{
key: 'eef',
label: 'EEF',
value: latestValidRatio(payload.eef, 'iotEef'),
unit: '',
},
];
return { metrics };
}
export function formatSummaryValue(metric: ReportSummaryMetric): string {
if (!isPresent(metric.value)) return '—';
if (metric.key === 'fcr' || metric.key === 'eef') {
return formatRatio(metric.value, 3);
}
if (
metric.key === 'uniformity' ||
metric.key === 'beratAvg' ||
metric.key === 'beratPanen' ||
metric.key === 'beratPakan' ||
metric.key === 'docWeight'
) {
return formatNumber(metric.value, 1, 0, true);
}
return formatNumber(metric.value, 0, 0, true);
}
+112
View File
@@ -0,0 +1,112 @@
import type {
DashboardReportCountingRow,
DashboardReportFeedInRow,
DashboardReportFeedOutRow,
DashboardReportFeedUseRow,
} from '../types/dashboardReport.ts';
export const REPORT_TOTAL_LABEL = 'Total';
const isPresentNumber = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value);
function sumPresent(values: Array<number | null | undefined>): number | null {
let total = 0;
let any = false;
for (const value of values) {
if (!isPresentNumber(value)) continue;
total += value;
any = true;
}
return any ? total : null;
}
const sackAccuracy = (manual: number | null, iot: number | null): number | null => {
if (manual != null && iot != null && manual > 0 && iot > 0) {
return Math.round((Math.min(manual, iot) / Math.max(manual, iot)) * 1000) / 10;
}
if (manual === 0 && iot === 0) return 100;
return null;
};
export interface CountingTotalsRow {
label: string;
countBefore: number | null;
mortality: number | null;
harvest: number | null;
harvestWeight: number | null;
countAfter: number | null;
iotCount: number | null;
accuracy: number | null;
}
export interface FeedInTotalsRow {
label: string;
manualIn: number | null;
iotIn: number | null;
accuracy: number | null;
}
export interface FeedUseTotalsRow {
label: string;
manualUse: number | null;
iotUse: number | null;
accuracy: number | null;
}
export interface FeedOutTotalsRow {
label: string;
manualOut: number | null;
iotOut: number | null;
accuracy: number | null;
}
export function countingTotals(rows: DashboardReportCountingRow[]): CountingTotalsRow | null {
if (!rows.length) return null;
return {
label: REPORT_TOTAL_LABEL,
countBefore: null,
mortality: sumPresent(rows.map((row) => row.mortality)),
harvest: sumPresent(rows.map((row) => row.harvest)),
harvestWeight: sumPresent(rows.map((row) => row.harvestWeight)),
countAfter: null,
iotCount: null,
accuracy: null,
};
}
export function feedInTotals(rows: DashboardReportFeedInRow[]): FeedInTotalsRow | null {
if (!rows.length) return null;
const manualIn = sumPresent(rows.map((row) => row.manualIn));
const iotIn = sumPresent(rows.map((row) => row.iotIn));
return {
label: REPORT_TOTAL_LABEL,
manualIn,
iotIn,
accuracy: sackAccuracy(manualIn, iotIn),
};
}
export function feedUseTotals(rows: DashboardReportFeedUseRow[]): FeedUseTotalsRow | null {
if (!rows.length) return null;
const manualUse = sumPresent(rows.map((row) => row.manualUse));
const iotUse = sumPresent(rows.map((row) => row.iotUse));
return {
label: REPORT_TOTAL_LABEL,
manualUse,
iotUse,
accuracy: sackAccuracy(manualUse, iotUse),
};
}
export function feedOutTotals(rows: DashboardReportFeedOutRow[]): FeedOutTotalsRow | null {
if (!rows.length) return null;
const manualOut = sumPresent(rows.map((row) => row.manualOut));
const iotOut = sumPresent(rows.map((row) => row.iotOut));
return {
label: REPORT_TOTAL_LABEL,
manualOut,
iotOut,
accuracy: sackAccuracy(manualOut, iotOut),
};
}
+18
View File
@@ -0,0 +1,18 @@
/** Keep rows on or before the dashboard publish cutoff date (ISO `YYYY-MM-DD`). */
export function throughVisibleDate<T extends { date: string }>(
rows: T[],
visibleThrough: string | null | undefined
): T[] {
if (!visibleThrough) return rows;
return rows.filter((row) => row.date <= visibleThrough);
}
/** True when `date` is on or before the publish cutoff (or cutoff is unknown). */
export function isOnOrBeforeVisibleDate(
date: string | null | undefined,
visibleThrough: string | null | undefined
): boolean {
if (!date) return false;
if (!visibleThrough) return true;
return date <= visibleThrough;
}
+107
View File
@@ -0,0 +1,107 @@
import { weightStandardData } from '../mockData/weightStandardData.ts';
/**
* Formats a weight value in grams into a locale-aware string. Values at or
* below 999 g stay in grams; larger values are shown in kg. Negative values
* (e.g. ADG during close-out) are shown with a minus sign.
*
* By default every value is shown with 4 decimal places (never rounded).
* Callers may override with options:
* - `decimals` decimal places to show (default 4)
* - `stripTrailingZeros` drop trailing zeros so the exact value is shown
* - `truncate` truncate instead of round (default false)
*/
export function formatWeight(
grams: number | null | undefined,
options?: { suffix?: string; decimals?: number; stripTrailingZeros?: boolean; truncate?: boolean }
): string {
const suffix = options?.suffix ?? '';
const decimals = options?.decimals ?? 4;
const minDecimals = options?.stripTrailingZeros ? 0 : decimals;
const format = (value: number, unit: string): string => {
let display = value;
if (options?.truncate) {
const factor = 10 ** decimals;
display = Math.trunc(display * factor) / factor;
}
const formatted = display.toLocaleString('id-ID', {
minimumFractionDigits: minDecimals,
maximumFractionDigits: decimals,
});
return `${formatted} ${unit}${suffix}`;
};
if (grams == null) return format(0, 'g');
const sign = grams < 0 ? '-' : '';
const abs = Math.abs(grams);
const unit = abs <= 999 ? 'g' : 'kg';
const display = abs <= 999 ? abs : abs / 1000;
return `${sign}${format(display, unit)}`;
}
/**
* Picks the Ross 308 standard row for a DOC (chick-in) weight.
* Table rows are keyed by DOC weight (g) 35..51; columns are age days 1..35.
* Weights outside that range use the closest available row (e.g. 20→35, 60→51).
*/
function pickStandardValues(docWeight: number): number[] {
const rows = weightStandardData.data;
if (rows.length === 0) return [];
const exact = rows.find((row) => row.day === docWeight);
if (exact) return exact.values;
let closest = rows[0]!;
for (const row of rows) {
if (Math.abs(row.day - docWeight) < Math.abs(closest.day - docWeight)) {
closest = row;
}
}
return closest.values;
}
/** Target body weight (grams) for a given age from the Ross 308 DOC-weight table. */
export function getTargetWeightForAge(age: number, docWeight: number): number | null {
const values = pickStandardValues(docWeight);
if (age < 1 || age > values.length) return null;
return values[age - 1] ?? null;
}
/** Target daily weight gain (grams/day) for a given age from the standard. */
export function getTargetGainForAge(age: number, docWeight: number): number | null {
const values = pickStandardValues(docWeight);
if (age < 2 || age > values.length) return null;
const current = values[age - 1];
const previous = values[age - 2];
if (current == null || previous == null) return null;
return current - previous;
}
export type WeightStatus = 'ok' | 'warning' | 'critical';
/** ADG threshold used by the old dashboard: >=60 ok, >=40 warning, else critical. */
export function adgStatus(adg: number | null | undefined): WeightStatus {
if (adg == null || adg < 0) return 'critical';
if (adg >= 60) return 'ok';
if (adg >= 40) return 'warning';
return 'critical';
}
/** Uniformity threshold (CP 707): >=80 ok, >=70 warning, else critical. */
export function uniformityStatus(uniformity: number | null | undefined): WeightStatus {
if (uniformity == null || uniformity < 0) return 'critical';
if (uniformity >= 80) return 'ok';
if (uniformity >= 70) return 'warning';
return 'critical';
}
/** Average-weight status from the deviation against the standard target. */
export function weightDeviationStatus(
actual: number | null | undefined,
target: number | null | undefined
): WeightStatus {
if (actual == null || target == null || target <= 0) return 'ok';
const deviation = ((actual - target) / target) * 100;
if (deviation < -20) return 'critical';
if (deviation < -10) return 'warning';
return 'ok';
}