From 5b74e0842cbc5629f5ed46d93b3803f2f87be083 Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Thu, 3 Sep 2026 09:55:30 +0700 Subject: [PATCH] adjust excel report format for more cleaner report rather than dumping raw data --- .../commands/seed_sukawarna_kandang4.py | 4 +- utils/reportExcel.ts | 463 ++++++++++++++---- 2 files changed, 384 insertions(+), 83 deletions(-) diff --git a/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py b/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py index 09b687d..9f788dd 100644 --- a/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py +++ b/backend/apps/jobs/management/commands/seed_sukawarna_kandang4.py @@ -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: diff --git a/utils/reportExcel.ts b/utils/reportExcel.ts index bf8a377..080d85b 100644 --- a/utils/reportExcel.ts +++ b/utils/reportExcel.ts @@ -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 = { + 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 { const workbook = new ExcelJS.Workbook(); workbook.creator = 'Dashboard CPSP'; + workbook.created = new Date(); - const counting = workbook.addWorksheet('Chicken Counting'); - counting.addRow([ - 'Tanggal', - 'Hari', - 'Stok awal', - 'Mortalitas', - 'Panen', - 'Stok akhir', - 'IoT', - 'Akurasi %', - ]); - for (const row of payload.counting) { - counting.addRow([ - row.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), - 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([ - 'Tanggal', - 'Hari', - 'AVG manual', - 'AVG IoT', - 'Keseragaman', - 'Selisih', - 'Akurasi %', - ]); - for (const row of payload.weight) { - weight.addRow([ - row.date, + 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), - 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', - '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), - ]); + 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();