From 661d78a22521579545810474636c1f782d01ba0f Mon Sep 17 00:00:00 2001 From: DongHeon Jang Date: Fri, 17 Apr 2026 11:27:29 +0900 Subject: [PATCH] Refactor report generation logic and enhance Excel/PDF output formatting - Updated the ReportService to improve the structure of Excel and PDF report generation, including the addition of fiscal period parameters. - Refactored methods to build summary and detail sheets in Excel, enhancing readability and styling. - Improved PDF document layout and added titles for better presentation of report data. - Enhanced integration tests to verify the correctness of generated reports and ensure the presence of expected artifacts. --- .../nexacrodemo/report/ReportService.java | 392 ++++++++++++++++-- .../ConsolidationIntegrationTest.java | 35 +- 2 files changed, 395 insertions(+), 32 deletions(-) diff --git a/server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportService.java b/server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportService.java index 832a77f..47686ae 100644 --- a/server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportService.java +++ b/server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportService.java @@ -8,15 +8,36 @@ import com.hanwha.nexacrodemo.consolidation.ConsolidationPayload; import com.hanwha.nexacrodemo.minio.ObjectStorageService; import com.hanwha.nexacrodemo.minio.StoredObject; import com.lowagie.text.Document; +import com.lowagie.text.Element; +import com.lowagie.text.Font; +import com.lowagie.text.FontFactory; +import com.lowagie.text.PageSize; import com.lowagie.text.Paragraph; +import com.lowagie.text.Phrase; +import com.lowagie.text.Rectangle; +import com.lowagie.text.pdf.PdfPCell; +import com.lowagie.text.pdf.PdfPTable; import com.lowagie.text.pdf.PdfWriter; +import java.awt.Color; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.FontUnderline; +import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpStatus; @@ -27,6 +48,10 @@ import org.springframework.stereotype.Service; @Service public class ReportService { + private static final DateTimeFormatter REPORT_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DecimalFormat AMOUNT_FORMAT = new DecimalFormat("#,##0.00"); + private static final Map LABELS = buildLabels(); + private final ReportMapper reportMapper; private final ConsolidationMapper consolidationMapper; private final ObjectStorageService objectStorageService; @@ -53,7 +78,7 @@ public class ReportService { public void generateReports(Long runId, String fiscalPeriod, ConsolidationPayload payload) { try { - byte[] excelBytes = buildExcel(payload); + byte[] excelBytes = buildExcel(fiscalPeriod, payload); byte[] pdfBytes = buildPdf(fiscalPeriod, payload); ReportArtifactCommand excelArtifact = new ReportArtifactCommand(); @@ -93,19 +118,13 @@ public class ReportService { .body(new ByteArrayResource(storedObject.getBytes())); } - private byte[] buildExcel(ConsolidationPayload payload) throws IOException { + private byte[] buildExcel(String fiscalPeriod, ConsolidationPayload payload) throws IOException { try (XSSFWorkbook workbook = new XSSFWorkbook()) { - Sheet summarySheet = workbook.createSheet("Summary"); - int rowIndex = 0; - for (Map.Entry metric : payload.getMetrics().entrySet()) { - Row row = summarySheet.createRow(rowIndex++); - row.createCell(0).setCellValue(metric.getKey()); - row.createCell(1).setCellValue(metric.getValue().doubleValue()); - } - - writeRows(workbook.createSheet("Contributions"), payload.getContributionRows()); - writeRows(workbook.createSheet("Eliminations"), payload.getEliminationRows()); - writeRows(workbook.createSheet("Forecast"), payload.getForecastRows()); + Map styles = createExcelStyles(workbook); + buildSummarySheet(workbook, fiscalPeriod, payload, styles); + buildDetailSheet(workbook, "Contributions", fiscalPeriod, payload.getContributionRows(), styles); + buildDetailSheet(workbook, "Eliminations", fiscalPeriod, payload.getEliminationRows(), styles); + buildDetailSheet(workbook, "Forecast", fiscalPeriod, payload.getForecastRows(), styles); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); workbook.write(outputStream); @@ -115,39 +134,352 @@ public class ReportService { private byte[] buildPdf(String fiscalPeriod, ConsolidationPayload payload) throws IOException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - Document document = new Document(); + Document document = new Document(PageSize.A4.rotate(), 36, 36, 36, 36); PdfWriter.getInstance(document, outputStream); document.open(); - document.add(new Paragraph("Hanwha Consolidation Demo")); - document.add(new Paragraph("Fiscal period: " + fiscalPeriod)); + addPdfTitle(document, "Hanwha Consolidation Summary", 18, new Color(24, 54, 93)); + addPdfTitle(document, "Fiscal period " + fiscalPeriod + " | Generated " + generatedAt(), 10, new Color(90, 96, 106)); document.add(new Paragraph(" ")); - for (Map.Entry metric : payload.getMetrics().entrySet()) { - document.add(new Paragraph(metric.getKey() + ": " + metric.getValue())); - } + addSummaryTable(document, payload.getMetrics()); + addDetailTable(document, "Contributions", payload.getContributionRows(), 8); + addDetailTable(document, "Eliminations", payload.getEliminationRows(), 8); + addDetailTable(document, "Forecast", payload.getForecastRows(), 8); document.close(); return outputStream.toByteArray(); } - private void writeRows(Sheet sheet, List> rows) { + private void buildSummarySheet( + XSSFWorkbook workbook, + String fiscalPeriod, + ConsolidationPayload payload, + Map styles + ) { + Sheet sheet = workbook.createSheet("Summary"); + sheet.setDisplayGridlines(false); + + Row titleRow = sheet.createRow(0); + titleRow.setHeightInPoints(28); + createCell(titleRow, 0, "Hanwha Consolidation Summary", styles.get("title")); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 3)); + + Row subtitleRow = sheet.createRow(1); + createCell(subtitleRow, 0, "Fiscal period " + fiscalPeriod + " | Generated " + generatedAt(), styles.get("subtitle")); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 3)); + + Row sectionRow = sheet.createRow(3); + createCell(sectionRow, 0, "Performance Snapshot", styles.get("section")); + + Row headerRow = sheet.createRow(4); + createCell(headerRow, 0, "Metric", styles.get("header")); + createCell(headerRow, 1, "Value", styles.get("header")); + + int rowIndex = 5; + for (Map.Entry metric : payload.getMetrics().entrySet()) { + Row row = sheet.createRow(rowIndex++); + createCell(row, 0, label(metric.getKey()), styles.get("text")); + createNumericCell(row, 1, metric.getValue(), styleForKey(styles, metric.getKey())); + } + + sheet.createFreezePane(0, 5); + sheet.setColumnWidth(0, 26 * 256); + sheet.setColumnWidth(1, 18 * 256); + sheet.setColumnWidth(2, 18 * 256); + sheet.setColumnWidth(3, 18 * 256); + } + + private void buildDetailSheet( + XSSFWorkbook workbook, + String sheetName, + String fiscalPeriod, + List> rows, + Map styles + ) { + Sheet sheet = workbook.createSheet(sheetName); + sheet.setDisplayGridlines(false); + int columnCount = rows.isEmpty() ? 1 : rows.get(0).size(); + + Row titleRow = sheet.createRow(0); + titleRow.setHeightInPoints(24); + createCell(titleRow, 0, sheetName + " Detail", styles.get("title")); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, Math.max(0, columnCount - 1))); + + Row subtitleRow = sheet.createRow(1); + createCell(subtitleRow, 0, "Fiscal period " + fiscalPeriod + " | Rows " + rows.size(), styles.get("subtitle")); + sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, Math.max(0, columnCount - 1))); + if (rows.isEmpty()) { - Row row = sheet.createRow(0); - row.createCell(0).setCellValue("No data"); + Row emptyRow = sheet.createRow(3); + createCell(emptyRow, 0, "No data available", styles.get("empty")); + sheet.setColumnWidth(0, 24 * 256); return; } - Row header = sheet.createRow(0); - int cellIndex = 0; - for (String key : rows.get(0).keySet()) { - header.createCell(cellIndex++).setCellValue(key); + List keys = List.copyOf(rows.get(0).keySet()); + Row headerRow = sheet.createRow(3); + for (int index = 0; index < keys.size(); index++) { + createCell(headerRow, index, label(keys.get(index)), styles.get("header")); } - int rowIndex = 1; + int rowIndex = 4; for (Map item : rows) { Row row = sheet.createRow(rowIndex++); - int valueIndex = 0; - for (Object value : item.values()) { - row.createCell(valueIndex++).setCellValue(value == null ? "" : String.valueOf(value)); + for (int cellIndex = 0; cellIndex < keys.size(); cellIndex++) { + String key = keys.get(cellIndex); + writeValueCell(row, cellIndex, key, item.get(key), styles); } } + + sheet.createFreezePane(0, 4); + sheet.setAutoFilter(new CellRangeAddress(3, rowIndex - 1, 0, keys.size() - 1)); + for (int index = 0; index < keys.size(); index++) { + sheet.autoSizeColumn(index); + int currentWidth = sheet.getColumnWidth(index); + sheet.setColumnWidth(index, Math.min(currentWidth + 1024, 28 * 256)); + } + } + + private Map createExcelStyles(XSSFWorkbook workbook) { + Map styles = new LinkedHashMap<>(); + short amountFormat = workbook.createDataFormat().getFormat("#,##0.00"); + short percentFormat = workbook.createDataFormat().getFormat("0.00%"); + + var titleFont = workbook.createFont(); + titleFont.setBold(true); + titleFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.WHITE.getIndex()); + titleFont.setFontHeightInPoints((short) 14); + + var subtitleFont = workbook.createFont(); + subtitleFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_80_PERCENT.getIndex()); + + var sectionFont = workbook.createFont(); + sectionFont.setBold(true); + sectionFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.DARK_BLUE.getIndex()); + + var headerFont = workbook.createFont(); + headerFont.setBold(true); + headerFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.WHITE.getIndex()); + + var emptyFont = workbook.createFont(); + emptyFont.setItalic(true); + emptyFont.setUnderline(FontUnderline.SINGLE); + emptyFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_50_PERCENT.getIndex()); + + CellStyle title = workbook.createCellStyle(); + title.setFont(titleFont); + title.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.DARK_BLUE.getIndex()); + title.setFillPattern(FillPatternType.SOLID_FOREGROUND); + title.setAlignment(HorizontalAlignment.LEFT); + title.setVerticalAlignment(VerticalAlignment.CENTER); + title.setBorderBottom(BorderStyle.THIN); + styles.put("title", title); + + CellStyle subtitle = workbook.createCellStyle(); + subtitle.setFont(subtitleFont); + subtitle.setAlignment(HorizontalAlignment.LEFT); + styles.put("subtitle", subtitle); + + CellStyle section = workbook.createCellStyle(); + section.setFont(sectionFont); + section.setAlignment(HorizontalAlignment.LEFT); + styles.put("section", section); + + CellStyle header = workbook.createCellStyle(); + header.setFont(headerFont); + header.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ORANGE.getIndex()); + header.setFillPattern(FillPatternType.SOLID_FOREGROUND); + header.setAlignment(HorizontalAlignment.CENTER); + header.setVerticalAlignment(VerticalAlignment.CENTER); + applyBorder(header); + styles.put("header", header); + + CellStyle text = workbook.createCellStyle(); + text.setAlignment(HorizontalAlignment.LEFT); + text.setVerticalAlignment(VerticalAlignment.CENTER); + applyBorder(text); + styles.put("text", text); + + CellStyle number = workbook.createCellStyle(); + number.cloneStyleFrom(text); + number.setAlignment(HorizontalAlignment.RIGHT); + number.setDataFormat(amountFormat); + styles.put("number", number); + + CellStyle ratio = workbook.createCellStyle(); + ratio.cloneStyleFrom(number); + ratio.setDataFormat(percentFormat); + styles.put("ratio", ratio); + + CellStyle empty = workbook.createCellStyle(); + empty.setFont(emptyFont); + styles.put("empty", empty); + + return styles; + } + + private void applyBorder(CellStyle style) { + style.setBorderTop(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + } + + private void writeValueCell(Row row, int cellIndex, String key, Object value, Map styles) { + if (value instanceof BigDecimal decimal) { + createNumericCell(row, cellIndex, decimal, styleForKey(styles, key)); + return; + } + if (value instanceof Number number) { + createNumericCell(row, cellIndex, BigDecimal.valueOf(number.doubleValue()), styleForKey(styles, key)); + return; + } + createCell(row, cellIndex, value == null ? "" : String.valueOf(value), styles.get("text")); + } + + private void createCell(Row row, int index, String value, CellStyle style) { + var cell = row.createCell(index); + cell.setCellValue(value); + cell.setCellStyle(style); + } + + private void createNumericCell(Row row, int index, BigDecimal value, CellStyle style) { + var cell = row.createCell(index); + cell.setCellValue(value.doubleValue()); + cell.setCellStyle(style); + } + + private CellStyle styleForKey(Map styles, String key) { + return key.toLowerCase().contains("ratio") ? styles.get("ratio") : styles.get("number"); + } + + private void addPdfTitle(Document document, String text, int size, Color color) { + Font font = FontFactory.getFont(FontFactory.HELVETICA_BOLD, size, color); + Paragraph paragraph = new Paragraph(text, font); + paragraph.setAlignment(Element.ALIGN_LEFT); + paragraph.setSpacingAfter(4f); + document.add(paragraph); + } + + private void addSummaryTable(Document document, Map metrics) { + PdfPTable table = new PdfPTable(new float[] {3f, 1.3f}); + table.setWidthPercentage(100f); + table.setSpacingBefore(6f); + table.setSpacingAfter(12f); + table.addCell(pdfHeaderCell("Metric")); + table.addCell(pdfHeaderCell("Value")); + for (Map.Entry metric : metrics.entrySet()) { + table.addCell(pdfBodyCell(label(metric.getKey()), Element.ALIGN_LEFT)); + table.addCell(pdfBodyCell(format(metric.getValue(), metric.getKey()), Element.ALIGN_RIGHT)); + } + document.add(table); + } + + private void addDetailTable(Document document, String title, List> rows, int maxRows) { + Font sectionFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, new Color(24, 54, 93)); + Paragraph section = new Paragraph(title, sectionFont); + section.setSpacingBefore(8f); + section.setSpacingAfter(6f); + document.add(section); + + if (rows.isEmpty()) { + document.add(new Paragraph("No data available", FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, new Color(120, 120, 120)))); + return; + } + + List keys = List.copyOf(rows.get(0).keySet()); + PdfPTable table = new PdfPTable(keys.size()); + table.setWidthPercentage(100f); + table.setSpacingAfter(8f); + for (String key : keys) { + table.addCell(pdfHeaderCell(label(key))); + } + + int visibleRows = Math.min(maxRows, rows.size()); + for (int rowIndex = 0; rowIndex < visibleRows; rowIndex++) { + Map row = rows.get(rowIndex); + for (String key : keys) { + int alignment = isNumericValue(row.get(key)) ? Element.ALIGN_RIGHT : Element.ALIGN_LEFT; + table.addCell(pdfBodyCell(format(row.get(key), key), alignment)); + } + } + document.add(table); + + if (rows.size() > maxRows) { + document.add(new Paragraph( + "Showing first " + maxRows + " of " + rows.size() + " rows.", + FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 9, new Color(120, 120, 120)) + )); + } + } + + private PdfPCell pdfHeaderCell(String text) { + PdfPCell cell = new PdfPCell(new Phrase(text, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE))); + cell.setHorizontalAlignment(Element.ALIGN_CENTER); + cell.setVerticalAlignment(Element.ALIGN_MIDDLE); + cell.setBackgroundColor(new Color(243, 127, 32)); + cell.setBorderColor(new Color(220, 220, 220)); + cell.setPadding(6f); + return cell; + } + + private PdfPCell pdfBodyCell(String text, int alignment) { + PdfPCell cell = new PdfPCell(new Phrase(text, FontFactory.getFont(FontFactory.HELVETICA, 9, Color.DARK_GRAY))); + cell.setHorizontalAlignment(alignment); + cell.setVerticalAlignment(Element.ALIGN_MIDDLE); + cell.setBorderColor(new Color(226, 229, 233)); + cell.setPadding(5f); + return cell; + } + + private boolean isNumericValue(Object value) { + return value instanceof Number || value instanceof BigDecimal; + } + + private String format(Object value, String key) { + if (value == null) { + return ""; + } + if (value instanceof BigDecimal decimal) { + return format(decimal, key); + } + if (value instanceof Number number) { + return format(BigDecimal.valueOf(number.doubleValue()), key); + } + return String.valueOf(value); + } + + private String format(BigDecimal value, String key) { + if (key.toLowerCase().contains("ratio")) { + return AMOUNT_FORMAT.format(value.multiply(BigDecimal.valueOf(100))) + "%"; + } + return AMOUNT_FORMAT.format(value); + } + + private String label(String key) { + return LABELS.getOrDefault(key, key); + } + + private String generatedAt() { + return ZonedDateTime.now(ZoneId.of("Asia/Seoul")).format(REPORT_TIME_FORMAT); + } + + private static Map buildLabels() { + Map labels = new LinkedHashMap<>(); + labels.put("acceptedTrialRows", "Accepted Trial Rows"); + labels.put("acceptedForecastRows", "Accepted Forecast Rows"); + labels.put("grossContributionKrw", "Gross Contribution (KRW)"); + labels.put("eliminationKrw", "Elimination (KRW)"); + labels.put("forecastKrw", "Forecast (KRW)"); + labels.put("netContributionKrw", "Net Contribution (KRW)"); + labels.put("entityCode", "Entity Code"); + labels.put("accountCode", "Account Code"); + labels.put("partnerEntityCode", "Partner Entity"); + labels.put("translatedAmount", "Translated Amount"); + labels.put("ownershipRatio", "Ownership Ratio"); + labels.put("finalAmount", "Final Amount"); + labels.put("internalTrade", "Internal Trade"); + labels.put("eliminationAmount", "Elimination Amount"); + labels.put("note", "Note"); + labels.put("scenarioCode", "Scenario"); + return labels; } } diff --git a/server/api/src/test/java/com/hanwha/nexacrodemo/consolidation/ConsolidationIntegrationTest.java b/server/api/src/test/java/com/hanwha/nexacrodemo/consolidation/ConsolidationIntegrationTest.java index 15f4481..d97fd9a 100644 --- a/server/api/src/test/java/com/hanwha/nexacrodemo/consolidation/ConsolidationIntegrationTest.java +++ b/server/api/src/test/java/com/hanwha/nexacrodemo/consolidation/ConsolidationIntegrationTest.java @@ -6,7 +6,12 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.hanwha.nexacrodemo.upload.TestWorkbookFactory; +import java.io.ByteArrayInputStream; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -29,6 +34,9 @@ class ConsolidationIntegrationTest { @Autowired private ConsolidationService consolidationService; + @Autowired + private ObjectMapper objectMapper; + @Test void validUploadsCanBeConsolidatedAndReported() throws Exception { MockHttpSession session = login("operator", "demo1234"); @@ -55,10 +63,33 @@ class ConsolidationIntegrationTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.datasets.runs[0].statusCode").value("SUCCESS")); - mockMvc.perform(get("/api/tx/reports/overview").session(session)) + MvcResult overviewResult = mockMvc.perform(get("/api/tx/reports/overview").session(session)) .andExpect(status().isOk()) .andExpect(jsonPath("$.datasets.artifacts.length()").value(2)) - .andExpect(jsonPath("$.datasets.jobLogs[0].logLevel").exists()); + .andExpect(jsonPath("$.datasets.jobLogs[0].logLevel").exists()) + .andReturn(); + + JsonNode artifacts = objectMapper.readTree(overviewResult.getResponse().getContentAsByteArray()) + .path("datasets") + .path("artifacts"); + long excelArtifactId = -1L; + for (JsonNode artifact : artifacts) { + if ("EXCEL".equals(artifact.path("artifactType").asText())) { + excelArtifactId = artifact.path("id").asLong(); + break; + } + } + Assertions.assertTrue(excelArtifactId > 0, "EXCEL artifact should exist"); + + MvcResult excelDownload = mockMvc.perform(get("/api/reports/{artifactId}/download", excelArtifactId).session(session)) + .andExpect(status().isOk()) + .andReturn(); + + try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(excelDownload.getResponse().getContentAsByteArray()))) { + Assertions.assertEquals("Hanwha Consolidation Summary", workbook.getSheet("Summary").getRow(0).getCell(0).getStringCellValue()); + Assertions.assertEquals("Entity Code", workbook.getSheet("Contributions").getRow(3).getCell(0).getStringCellValue()); + Assertions.assertNotNull(workbook.getSheet("Forecast")); + } } private void upload(MockHttpSession session, String templateCode, String fileName, byte[] content) throws Exception {