first commit
This commit is contained in:
commit
b54624be96
226 files changed
+108840
No files matched your search
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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())}`;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user