adjust excel report format for more cleaner report rather than dumping raw data
This commit is contained in:
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_total_after = mort_total + mort_today
|
||||
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
|
||||
# harvest-weighted running mean carried forward on idle days.
|
||||
if harvest_today:
|
||||
|
||||
+366
-67
@@ -1,108 +1,407 @@
|
||||
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';
|
||||
|
||||
const na = (value: number | null | undefined): number | string =>
|
||||
value == null || !Number.isFinite(value) ? 'N/A' : value;
|
||||
const COLORS = {
|
||||
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> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Dashboard CPSP';
|
||||
workbook.created = new Date();
|
||||
|
||||
const counting = workbook.addWorksheet('Chicken Counting');
|
||||
counting.addRow([
|
||||
addSummarySheet(workbook, payload);
|
||||
|
||||
const countingTotal = countingTotals(payload.counting);
|
||||
addTable(
|
||||
workbook.addWorksheet('Hitung Ayam'),
|
||||
'Hitung Ayam harian',
|
||||
[
|
||||
'Tanggal',
|
||||
'Hari',
|
||||
'Umur (hari)',
|
||||
'Stok awal',
|
||||
'Mortalitas',
|
||||
'Panen',
|
||||
'Berat panen',
|
||||
'Stok akhir',
|
||||
'IoT',
|
||||
'Akurasi %',
|
||||
]);
|
||||
for (const row of payload.counting) {
|
||||
counting.addRow([
|
||||
row.date,
|
||||
],
|
||||
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),
|
||||
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');
|
||||
weight.addRow([
|
||||
addTable(
|
||||
workbook.addWorksheet('Berat Ayam'),
|
||||
'Berat Ayam harian',
|
||||
[
|
||||
'Tanggal',
|
||||
'Hari',
|
||||
'AVG manual',
|
||||
'AVG IoT',
|
||||
'Umur (hari)',
|
||||
'Rata-rata manual',
|
||||
'Rata-rata IoT',
|
||||
'Keseragaman',
|
||||
'Selisih',
|
||||
'Akurasi %',
|
||||
]);
|
||||
for (const row of payload.weight) {
|
||||
weight.addRow([
|
||||
row.date,
|
||||
],
|
||||
payload.weight.map((row) => [
|
||||
formatDateId(row.date),
|
||||
row.day,
|
||||
na(row.avgManual),
|
||||
na(row.avgIot),
|
||||
na(row.uniformity),
|
||||
pct(row.uniformity),
|
||||
na(row.difference),
|
||||
na(row.accuracy),
|
||||
]);
|
||||
}
|
||||
pct(row.accuracy),
|
||||
])
|
||||
);
|
||||
|
||||
const feedInSheet = workbook.addWorksheet('Feed In');
|
||||
feedInSheet.addRow(['Tanggal', 'Manual in', 'IoT in', 'Akurasi in %']);
|
||||
for (const row of payload.feedIn) {
|
||||
feedInSheet.addRow([row.date, na(row.manualIn), na(row.iotIn), na(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 feedUseSheet = workbook.addWorksheet('Feed Use');
|
||||
feedUseSheet.addRow(['Tanggal', 'Hari', 'Manual use', 'IoT use', 'Akurasi use %']);
|
||||
for (const row of payload.feedUse) {
|
||||
feedUseSheet.addRow([row.date, row.day, na(row.manualUse), na(row.iotUse), na(row.accuracy)]);
|
||||
}
|
||||
|
||||
const feedOutSheet = workbook.addWorksheet('Feed Out');
|
||||
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([
|
||||
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.iotFcr),
|
||||
na(row.feedTotal),
|
||||
na(row.iotWeight),
|
||||
na(row.chickenLife),
|
||||
na(row.harvestWeightTotal),
|
||||
]);
|
||||
}
|
||||
na(row.manualUse),
|
||||
na(row.iotUse),
|
||||
pct(row.accuracy),
|
||||
]),
|
||||
feedUseTotal
|
||||
? [
|
||||
feedUseTotal.label,
|
||||
'—',
|
||||
na(feedUseTotal.manualUse),
|
||||
na(feedUseTotal.iotUse),
|
||||
pct(feedUseTotal.accuracy),
|
||||
]
|
||||
: null
|
||||
);
|
||||
|
||||
const eefSheet = workbook.addWorksheet('EEF');
|
||||
eefSheet.addRow([
|
||||
'Hari',
|
||||
'EEF',
|
||||
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',
|
||||
]);
|
||||
for (const row of payload.eef) {
|
||||
eefSheet.addRow([
|
||||
row.day,
|
||||
na(row.iotEef),
|
||||
na(row.chickenLifePercentage),
|
||||
na(row.iotAverageHarvestBw),
|
||||
na(row.iotAverageDayWeight),
|
||||
na(row.iotFcr),
|
||||
]);
|
||||
'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();
|
||||
|
||||
Reference in new issue
Block a user