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();
});
});