adjust excel report format for more cleaner report rather than dumping raw data

This commit is contained in:
Alberto-Audrix committed 2026-09-03 09:55:30 +07:00
1 parent 41905078df
commit 5b74e0842c
2 files changed
+369 -68

No files matched your search

@@ -324,7 +324,9 @@ class Command(BaseCommand):
mort_today = MORTALITY[i] mort_today = MORTALITY[i]
mort_total_after = mort_total + mort_today mort_total_after = mort_total + mort_today
life = DOC_IN_COUNT - mort_total_after - harvest_total life = DOC_IN_COUNT - mort_total_after - harvest_total
life_pct = life / DOC_IN_COUNT * 100.0 if DOC_IN_COUNT else 0.0 # Liveability for EEF: birds still accounted for (DOC minus mortality),
# not remaining in-house stock (which drops during harvest).
life_pct = (DOC_IN_COUNT - mort_total_after) / DOC_IN_COUNT * 100.0 if DOC_IN_COUNT else 0.0
# Running averages: flock weight before the first harvest, then the # Running averages: flock weight before the first harvest, then the
# harvest-weighted running mean carried forward on idle days. # harvest-weighted running mean carried forward on idle days.
if harvest_today: if harvest_today:
+366 -67
View File
@@ -1,108 +1,407 @@
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
import type { DashboardReportPayload } from '../types/dashboardReport.ts'; import type { DashboardReportPayload } from '../types/dashboardReport.ts';
import {
countingTotals,
feedInTotals,
feedOutTotals,
feedUseTotals,
} from './reportTotals.ts';
import { formatDateId, formatDateRange, formatNumber, formatRatio } from './format.ts';
const na = (value: number | null | undefined): number | string => const COLORS = {
value == null || !Number.isFinite(value) ? 'N/A' : value; headerFill: 'FFF8FAFC',
headerFont: 'FF475569',
accentFill: 'FF6D28D0',
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`;
};
const 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 (isPresent(value)) return value;
}
return null;
};
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 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('Dicetak', printedDate);
if (isEndCycle) {
const finalFcr = latestValidRatio(payload.fcr, 'iotFcr');
const finalEef = latestValidRatio(payload.eef, 'iotEef');
sheet.addRow([]);
addMeta('FCR akhir', ratio(finalFcr), true);
addMeta('EEF akhir', ratio(finalEef), true);
}
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> { export async function downloadReportExcel(payload: DashboardReportPayload): Promise<void> {
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
workbook.creator = 'Dashboard CPSP'; workbook.creator = 'Dashboard CPSP';
workbook.created = new Date();
const counting = workbook.addWorksheet('Chicken Counting'); addSummarySheet(workbook, payload);
counting.addRow([
const countingTotal = countingTotals(payload.counting);
addTable(
workbook.addWorksheet('Hitung Ayam'),
'Hitung Ayam harian',
[
'Tanggal', 'Tanggal',
'Hari', 'Umur (hari)',
'Stok awal', 'Stok awal',
'Mortalitas', 'Mortalitas',
'Panen', 'Panen',
'Berat panen',
'Stok akhir', 'Stok akhir',
'IoT', 'IoT',
'Akurasi %', 'Akurasi %',
]); ],
for (const row of payload.counting) { payload.counting.map((row) => [
counting.addRow([ formatDateId(row.date),
row.date,
row.day, row.day,
na(row.countBefore), na(row.countBefore),
na(row.mortality), na(row.mortality),
na(row.harvest), na(row.harvest),
na(row.harvestWeight),
na(row.countAfter), na(row.countAfter),
na(row.iotCount), na(row.iotCount),
na(row.accuracy), 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
);
const weight = workbook.addWorksheet('Weight'); addTable(
weight.addRow([ workbook.addWorksheet('Berat Ayam'),
'Berat Ayam harian',
[
'Tanggal', 'Tanggal',
'Hari', 'Umur (hari)',
'AVG manual', 'Rata-rata manual',
'AVG IoT', 'Rata-rata IoT',
'Keseragaman', 'Keseragaman',
'Selisih', 'Selisih',
'Akurasi %', 'Akurasi %',
]); ],
for (const row of payload.weight) { payload.weight.map((row) => [
weight.addRow([ formatDateId(row.date),
row.date,
row.day, row.day,
na(row.avgManual), na(row.avgManual),
na(row.avgIot), na(row.avgIot),
na(row.uniformity), pct(row.uniformity),
na(row.difference), na(row.difference),
na(row.accuracy), pct(row.accuracy),
]); ])
} );
const feedInSheet = workbook.addWorksheet('Feed In'); const feedInTotal = feedInTotals(payload.feedIn);
feedInSheet.addRow(['Tanggal', 'Manual in', 'IoT in', 'Akurasi in %']); addTable(
for (const row of payload.feedIn) { workbook.addWorksheet('Karung Masuk'),
feedInSheet.addRow([row.date, na(row.manualIn), na(row.iotIn), na(row.accuracy)]); '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 feedUseSheet = workbook.addWorksheet('Feed Use'); const feedUseTotal = feedUseTotals(payload.feedUse);
feedUseSheet.addRow(['Tanggal', 'Hari', 'Manual use', 'IoT use', 'Akurasi use %']); addTable(
for (const row of payload.feedUse) { workbook.addWorksheet('Karung Dituang'),
feedUseSheet.addRow([row.date, row.day, na(row.manualUse), na(row.iotUse), na(row.accuracy)]); 'Karung dituang',
} ['Tanggal', 'Umur (hari)', 'Manual dituang', 'IoT dituang', 'Akurasi %'],
payload.feedUse.map((row) => [
const feedOutSheet = workbook.addWorksheet('Feed Out'); formatDateId(row.date),
feedOutSheet.addRow(['Tanggal', 'Manual out', 'IoT out', 'Akurasi out %']);
for (const row of payload.feedOut) {
feedOutSheet.addRow([row.date, na(row.manualOut), na(row.iotOut), na(row.accuracy)]);
}
const fcrSheet = workbook.addWorksheet('FCR');
fcrSheet.addRow(['Hari', 'FCR', 'Total Pakan', 'Bobot IoT', 'Ayam Hidup', 'Total Bobot Panen']);
for (const row of payload.fcr) {
fcrSheet.addRow([
row.day, row.day,
na(row.iotFcr), na(row.manualUse),
na(row.feedTotal), na(row.iotUse),
na(row.iotWeight), pct(row.accuracy),
na(row.chickenLife), ]),
na(row.harvestWeightTotal), feedUseTotal
]); ? [
} feedUseTotal.label,
'—',
na(feedUseTotal.manualUse),
na(feedUseTotal.iotUse),
pct(feedUseTotal.accuracy),
]
: null
);
const eefSheet = workbook.addWorksheet('EEF'); const feedOutTotal = feedOutTotals(payload.feedOut);
eefSheet.addRow([ addTable(
'Hari', workbook.addWorksheet('Karung Keluar'),
'EEF', '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', 'Persentase Ayam Hidup',
'BW Rata-rata Panen', 'BW Rata-rata Panen',
'Umur Rata-rata Panen', 'Umur Rata-rata Panen',
'FCR', 'FCR',
]); 'EEF',
for (const row of payload.eef) { ],
eefSheet.addRow([ payload.eef.map((row) => [
row.day, `Hari ${row.day}`,
na(row.iotEef), qty(row.chickenLifePercentage, 3, '%'),
na(row.chickenLifePercentage), qty(row.iotAverageHarvestBw, 2, row.iotAverageHarvestBw != null ? ' g' : ''),
na(row.iotAverageHarvestBw), qty(row.iotAverageDayWeight, 2),
na(row.iotAverageDayWeight), ratio(row.iotFcr),
na(row.iotFcr), ratio(row.iotEef),
]); ])
);
} }
const buffer = await workbook.xlsx.writeBuffer(); const buffer = await workbook.xlsx.writeBuffer();