diff --git a/build.gradle b/build.gradle
index 9fb65b3..348081a 100644
--- a/build.gradle
+++ b/build.gradle
@@ -35,6 +35,10 @@ dependencies {
// CSV 파싱 의존성
implementation 'org.apache.commons:commons-csv:1.10.0'
+ // Apache POI - DOCX 문서 생성을 위한 의존성
+ implementation 'org.apache.poi:poi:5.2.3'
+ implementation 'org.apache.poi:poi-ooxml:5.2.3'
+
// Swagger OpenAPI Dependencies
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
implementation 'io.swagger.core.v3:swagger-annotations:2.2.20'
diff --git a/src/main/java/dev/gyeoul/esginsightboard/controller/ReportController.java b/src/main/java/dev/gyeoul/esginsightboard/controller/ReportController.java
new file mode 100644
index 0000000..0b45c1b
--- /dev/null
+++ b/src/main/java/dev/gyeoul/esginsightboard/controller/ReportController.java
@@ -0,0 +1,99 @@
+package dev.gyeoul.esginsightboard.controller;
+
+import dev.gyeoul.esginsightboard.service.ReportGenerationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * ESG 보고서 생성 및 다운로드를 위한 REST API 컨트롤러
+ *
+ * 이 컨트롤러는 회사별 ESG 데이터를 기반으로 생성한 GRI 보고서를 다운로드할 수 있는 엔드포인트를 제공합니다.
+ *
+ */
+@RestController
+@RequestMapping("/api/reports")
+@RequiredArgsConstructor
+@Tag(name = "ESG 보고서", description = "ESG 보고서 생성 및 다운로드 API")
+public class ReportController {
+
+ private final ReportGenerationService reportGenerationService;
+
+ /**
+ * 회사 ID를 기반으로 ESG 보고서를 생성하고 다운로드합니다.
+ *
+ * @param companyId 회사 ID
+ * @return 생성된 DOCX 보고서 파일
+ * @throws IOException 파일 생성 중 오류 발생 시
+ */
+ @Operation(
+ summary = "ESG 보고서 다운로드",
+ description = "회사 ID를 기반으로 GRI 프레임워크에 맞춘 ESG 보고서를 DOCX 형식으로 생성하여 다운로드합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "ESG 보고서 다운로드 성공",
+ content = @Content(mediaType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
+ ),
+ @ApiResponse(
+ responseCode = "404",
+ description = "회사 정보를 찾을 수 없음",
+ content = @Content
+ ),
+ @ApiResponse(
+ responseCode = "400",
+ description = "보고서 생성 중 오류 발생 (ESG 데이터 없음)",
+ content = @Content
+ ),
+ @ApiResponse(
+ responseCode = "500",
+ description = "서버 내부 오류",
+ content = @Content
+ )
+ })
+ @GetMapping("/esg/{companyId}")
+ public ResponseEntity downloadEsgReport(
+ @Parameter(description = "보고서를 생성할 회사의 ID", required = true)
+ @PathVariable Long companyId) throws IOException {
+ // 보고서 생성 서비스 호출
+ byte[] report = reportGenerationService.generateEsgReportByCompanyId(companyId);
+
+ // 파일명 생성 (회사 ID + 현재 날짜)
+ String filename = "ESG_Report_Company_" + companyId + "_" +
+ LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + ".docx";
+
+ // 한글 파일명을 위한 인코딩
+ String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8.toString())
+ .replaceAll("\\+", "%20");
+
+ // HTTP 헤더 설정
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ headers.setContentDispositionFormData("attachment", encodedFilename);
+ headers.setContentLength(report.length);
+
+ // 응답 반환
+ return ResponseEntity.ok()
+ .headers(headers)
+ .body(report);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/dev/gyeoul/esginsightboard/service/ReportGenerationService.java b/src/main/java/dev/gyeoul/esginsightboard/service/ReportGenerationService.java
new file mode 100644
index 0000000..20808b1
--- /dev/null
+++ b/src/main/java/dev/gyeoul/esginsightboard/service/ReportGenerationService.java
@@ -0,0 +1,396 @@
+package dev.gyeoul.esginsightboard.service;
+
+import dev.gyeoul.esginsightboard.entity.Company;
+import dev.gyeoul.esginsightboard.entity.GriDataItem;
+import dev.gyeoul.esginsightboard.repository.CompanyRepository;
+import dev.gyeoul.esginsightboard.repository.GriDataItemRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.xwpf.usermodel.*;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
+import org.apache.poi.xwpf.usermodel.BreakType;
+import org.apache.poi.xwpf.usermodel.Borders;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * ESG 보고서 생성 서비스
+ *
+ * 이 서비스는 Apache POI를 활용하여 회사의 ESG 데이터를 기반으로 GRI 프레임워크에 맞는 DOCX 형식의 보고서를 생성합니다.
+ *
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ReportGenerationService {
+
+ private final CompanyRepository companyRepository;
+ private final GriDataItemRepository griDataItemRepository;
+
+ // 일반적인 날짜 형식
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일");
+
+ // 보고서 제목과 헤더 형식
+ private static final String REPORT_TITLE_FORMAT = "%s ESG 지속가능경영보고서";
+ private static final String REPORT_SUBTITLE = "GRI Standards 기반";
+ private static final String REPORT_PERIOD_FORMAT = "보고 기간: %s ~ %s";
+
+ /**
+ * 회사 ID를 기반으로 ESG 보고서를 생성합니다.
+ *
+ * @param companyId 회사 ID
+ * @return 생성된 DOCX 문서의 바이트 배열
+ * @throws IOException 파일 생성 중 오류 발생 시
+ */
+ @Transactional(readOnly = true)
+ public byte[] generateEsgReportByCompanyId(Long companyId) throws IOException {
+ // 회사 정보 조회
+ Company company = companyRepository.findById(companyId)
+ .orElseThrow(() -> new IllegalArgumentException("회사 정보를 찾을 수 없습니다: " + companyId));
+
+ // GRI 데이터 항목 조회
+ List dataItems = company.getGriDataItems();
+
+ // 빈 데이터 확인
+ if (dataItems.isEmpty()) {
+ throw new IllegalStateException("보고서에 포함할 ESG 데이터가 없습니다.");
+ }
+
+ // 보고 기간 결정 (데이터 항목의 보고 기간을 기준으로)
+ LocalDate reportStartDate = determineReportStartDate(dataItems);
+ LocalDate reportEndDate = determineReportEndDate(dataItems);
+
+ // DOCX 문서 생성
+ try (XWPFDocument document = new XWPFDocument()) {
+ // 문서 제목 및 메타데이터 설정
+ createDocumentTitle(document, company.getName(), reportStartDate, reportEndDate);
+
+ // 회사 정보 섹션 추가
+ addCompanyInfoSection(document, company);
+
+ // ESG 데이터를 카테고리별로 그룹화
+ Map> categorizedItems = categorizeDataItems(dataItems);
+
+ // 환경(E) 섹션 추가
+ if (categorizedItems.containsKey("E")) {
+ addCategorySection(document, "환경(Environmental)", categorizedItems.get("E"));
+ }
+
+ // 사회(S) 섹션 추가
+ if (categorizedItems.containsKey("S")) {
+ addCategorySection(document, "사회(Social)", categorizedItems.get("S"));
+ }
+
+ // 지배구조(G) 섹션 추가
+ if (categorizedItems.containsKey("G")) {
+ addCategorySection(document, "지배구조(Governance)", categorizedItems.get("G"));
+ }
+
+ // 보고서 마무리 섹션 추가 (면책 조항, 연락처 등)
+ addFooterSection(document, company.getName());
+
+ // 문서를 바이트 배열로 변환
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ document.write(outputStream);
+ return outputStream.toByteArray();
+ }
+ }
+
+ /**
+ * 문서 제목과 표지를 생성합니다.
+ */
+ private void createDocumentTitle(XWPFDocument document, String companyName,
+ LocalDate startDate, LocalDate endDate) {
+ // 제목 페이지 생성
+ XWPFParagraph titleParagraph = document.createParagraph();
+ titleParagraph.setAlignment(ParagraphAlignment.CENTER);
+ titleParagraph.setSpacingAfter(500);
+
+ XWPFRun titleRun = titleParagraph.createRun();
+ titleRun.setText(String.format(REPORT_TITLE_FORMAT, companyName));
+ titleRun.setBold(true);
+ titleRun.setFontSize(24);
+ titleRun.addBreak();
+ titleRun.addBreak();
+
+ // 부제목
+ XWPFRun subtitleRun = titleParagraph.createRun();
+ subtitleRun.setText(REPORT_SUBTITLE);
+ subtitleRun.setFontSize(16);
+ subtitleRun.addBreak();
+ subtitleRun.addBreak();
+
+ // 보고 기간
+ XWPFRun periodRun = titleParagraph.createRun();
+ String formattedStartDate = startDate.format(DATE_FORMATTER);
+ String formattedEndDate = endDate.format(DATE_FORMATTER);
+ periodRun.setText(String.format(REPORT_PERIOD_FORMAT, formattedStartDate, formattedEndDate));
+ periodRun.setFontSize(12);
+ periodRun.addBreak();
+ periodRun.addBreak();
+
+ // 현재 날짜 (보고서 작성일)
+ XWPFRun dateRun = titleParagraph.createRun();
+ String today = LocalDate.now().format(DATE_FORMATTER);
+ dateRun.setText("작성일: " + today);
+ dateRun.setFontSize(12);
+
+ // 페이지 나누기
+ XWPFParagraph pageBreak = document.createParagraph();
+ pageBreak.createRun().addBreak(BreakType.PAGE);
+ }
+
+ /**
+ * 회사 정보 섹션을 추가합니다.
+ */
+ private void addCompanyInfoSection(XWPFDocument document, Company company) {
+ // 회사 정보 섹션 제목
+ XWPFParagraph sectionTitle = document.createParagraph();
+ sectionTitle.setStyle("Heading1");
+
+ XWPFRun titleRun = sectionTitle.createRun();
+ titleRun.setText("1. 회사 개요");
+ titleRun.setBold(true);
+ titleRun.setFontSize(16);
+
+ // 회사 정보 테이블 생성
+ XWPFTable table = document.createTable(5, 2);
+ table.setWidth("100%");
+
+ // 테이블 스타일 설정
+ CTTblPr tblPr = table.getCTTbl().getTblPr();
+ tblPr.getTblW().setType(STTblWidth.PCT);
+ tblPr.getTblW().setW(new BigInteger("5000"));
+
+ // 회사명
+ table.getRow(0).getCell(0).setText("회사명");
+ table.getRow(0).getCell(1).setText(company.getName());
+
+ // 사업자등록번호
+ table.getRow(1).getCell(0).setText("사업자등록번호");
+ table.getRow(1).getCell(1).setText(company.getBusinessNumber() != null ?
+ company.getBusinessNumber() : "정보 없음");
+
+ // 업종
+ table.getRow(2).getCell(0).setText("업종");
+ table.getRow(2).getCell(1).setText(company.getIndustry() != null ?
+ company.getIndustry() : "정보 없음");
+
+ // 섹터
+ table.getRow(3).getCell(0).setText("섹터");
+ table.getRow(3).getCell(1).setText(company.getSector() != null ?
+ company.getSector() : "정보 없음");
+
+ // 직원 수
+ table.getRow(4).getCell(0).setText("직원 수");
+ table.getRow(4).getCell(1).setText(company.getEmployeeCount() != null ?
+ company.getEmployeeCount().toString() + "명" : "정보 없음");
+
+ // 회사 설명이 있는 경우 추가
+ if (company.getDescription() != null && !company.getDescription().isEmpty()) {
+ XWPFParagraph descParagraph = document.createParagraph();
+ descParagraph.setSpacingBefore(300);
+
+ XWPFRun descTitleRun = descParagraph.createRun();
+ descTitleRun.setText("회사 소개");
+ descTitleRun.setBold(true);
+ descTitleRun.addBreak();
+
+ XWPFRun descRun = descParagraph.createRun();
+ descRun.setText(company.getDescription());
+ }
+
+ // 페이지 나누기
+ XWPFParagraph pageBreak = document.createParagraph();
+ pageBreak.createRun().addBreak(BreakType.PAGE);
+ }
+
+ /**
+ * ESG 카테고리별 섹션을 추가합니다.
+ */
+ private void addCategorySection(XWPFDocument document, String categoryTitle, List items) {
+ // 카테고리 섹션 제목
+ XWPFParagraph sectionTitle = document.createParagraph();
+ sectionTitle.setStyle("Heading1");
+
+ XWPFRun titleRun = sectionTitle.createRun();
+ titleRun.setText(categoryTitle);
+ titleRun.setBold(true);
+ titleRun.setFontSize(16);
+
+ // 카테고리별 데이터 항목을 표준 코드 기준으로 그룹화
+ Map> standardCodeGroups = items.stream()
+ .collect(Collectors.groupingBy(GriDataItem::getStandardCode));
+
+ // 각 표준 코드 그룹에 대한 서브섹션 추가
+ int count = 0;
+ for (Map.Entry> entry : standardCodeGroups.entrySet()) {
+ String standardCode = entry.getKey();
+ List standardItems = entry.getValue();
+
+ // 표준 코드 서브섹션 제목
+ XWPFParagraph standardTitle = document.createParagraph();
+ standardTitle.setStyle("Heading2");
+
+ XWPFRun standardRun = standardTitle.createRun();
+ // 첫 번째 항목의 공시 제목을 가져와 표준 이름으로 사용
+ String disclosureTitle = standardItems.get(0).getDisclosureTitle();
+ standardRun.setText(standardCode + " - " + disclosureTitle);
+ standardRun.setBold(true);
+ standardRun.setFontSize(14);
+
+ // 각 공시 항목에 대한 내용 추가
+ for (GriDataItem item : standardItems) {
+ // 공시 코드 및 제목
+ XWPFParagraph disclosureParagraph = document.createParagraph();
+ disclosureParagraph.setStyle("Heading3");
+
+ XWPFRun disclosureRun = disclosureParagraph.createRun();
+ disclosureRun.setText(item.getDisclosureCode() + ": " + item.getDisclosureTitle());
+ disclosureRun.setBold(true);
+ disclosureRun.setFontSize(12);
+
+ // 공시 값(텍스트) 추가
+ if (item.getDisclosureValue() != null && !item.getDisclosureValue().isEmpty()) {
+ XWPFParagraph valueParagraph = document.createParagraph();
+
+ XWPFRun valueRun = valueParagraph.createRun();
+ valueRun.setText(item.getDisclosureValue());
+ }
+
+ // 정량적 값과 단위 추가
+ if (item.getNumericValue() != null) {
+ XWPFParagraph numericParagraph = document.createParagraph();
+
+ XWPFRun numericRun = numericParagraph.createRun();
+ String unit = item.getUnit() != null ? item.getUnit() : "";
+ numericRun.setText("값: " + item.getNumericValue() + " " + unit);
+ numericRun.setBold(true);
+ }
+
+ // 보고 기간 추가
+ if (item.getReportingPeriodStart() != null && item.getReportingPeriodEnd() != null) {
+ XWPFParagraph periodParagraph = document.createParagraph();
+
+ XWPFRun periodRun = periodParagraph.createRun();
+ String startDate = item.getReportingPeriodStart().format(DATE_FORMATTER);
+ String endDate = item.getReportingPeriodEnd().format(DATE_FORMATTER);
+ periodRun.setText("보고 기간: " + startDate + " ~ " + endDate);
+ periodRun.setItalic(true);
+ periodRun.setFontSize(10);
+ }
+
+ // 검증 상태 추가
+ if (item.getVerificationStatus() != null && !item.getVerificationStatus().isEmpty()) {
+ XWPFParagraph verificationParagraph = document.createParagraph();
+
+ XWPFRun verificationRun = verificationParagraph.createRun();
+ String provider = item.getVerificationProvider() != null ?
+ " (" + item.getVerificationProvider() + ")" : "";
+ verificationRun.setText("검증 상태: " + item.getVerificationStatus() + provider);
+ verificationRun.setItalic(true);
+ verificationRun.setFontSize(10);
+ }
+
+ // 항목 간 구분선
+ if (standardItems.indexOf(item) < standardItems.size() - 1) {
+ XWPFParagraph separator = document.createParagraph();
+ separator.setBorderBottom(Borders.SINGLE);
+ separator.setSpacingAfter(200);
+ }
+ }
+
+ count++;
+ // 마지막 표준 코드가 아니면 페이지 나누기 추가
+ if (count < standardCodeGroups.size()) {
+ XWPFParagraph pageBreak = document.createParagraph();
+ pageBreak.createRun().addBreak(BreakType.PAGE);
+ }
+ }
+
+ // 카테고리 섹션 종료 후 페이지 나누기
+ XWPFParagraph pageBreak = document.createParagraph();
+ pageBreak.createRun().addBreak(BreakType.PAGE);
+ }
+
+ /**
+ * 보고서 마무리 섹션을 추가합니다.
+ */
+ private void addFooterSection(XWPFDocument document, String companyName) {
+ // 마무리 섹션 제목
+ XWPFParagraph sectionTitle = document.createParagraph();
+ sectionTitle.setStyle("Heading1");
+
+ XWPFRun titleRun = sectionTitle.createRun();
+ titleRun.setText("면책 조항 및 연락처");
+ titleRun.setBold(true);
+ titleRun.setFontSize(16);
+
+ // 면책 조항
+ XWPFParagraph disclaimerParagraph = document.createParagraph();
+
+ XWPFRun disclaimerRun = disclaimerParagraph.createRun();
+ disclaimerRun.setText("본 보고서는 " + companyName + "의 ESG 활동과 성과를 GRI 표준에 따라 작성한 것입니다. " +
+ "보고서에 포함된 정보는 작성 시점을 기준으로 하며, 예고 없이 변경될 수 있습니다. " +
+ "본 보고서에 포함된 정보의 정확성과 완전성을 보장하기 위해 최선을 다하였으나, " +
+ "모든 내용이 검증되었음을 의미하지는 않습니다.");
+
+ // 연락처
+ XWPFParagraph contactParagraph = document.createParagraph();
+ contactParagraph.setSpacingBefore(300);
+
+ XWPFRun contactTitleRun = contactParagraph.createRun();
+ contactTitleRun.setText("문의처");
+ contactTitleRun.setBold(true);
+ contactTitleRun.addBreak();
+
+ XWPFRun contactRun = contactParagraph.createRun();
+ contactRun.setText(companyName + " ESG 지속가능경영팀");
+ contactRun.addBreak();
+ contactRun.setText("이메일: esg@" + companyName.toLowerCase().replaceAll("\\s+", "") + ".com");
+ }
+
+ /**
+ * 데이터 항목들에서 보고 시작일을 결정합니다.
+ */
+ private LocalDate determineReportStartDate(List items) {
+ return items.stream()
+ .filter(item -> item.getReportingPeriodStart() != null)
+ .map(GriDataItem::getReportingPeriodStart)
+ .min(LocalDate::compareTo)
+ .orElse(LocalDate.now().minusYears(1));
+ }
+
+ /**
+ * 데이터 항목들에서 보고 종료일을 결정합니다.
+ */
+ private LocalDate determineReportEndDate(List items) {
+ return items.stream()
+ .filter(item -> item.getReportingPeriodEnd() != null)
+ .map(GriDataItem::getReportingPeriodEnd)
+ .max(LocalDate::compareTo)
+ .orElse(LocalDate.now());
+ }
+
+ /**
+ * 데이터 항목들을 ESG 카테고리별로 분류합니다.
+ */
+ private Map> categorizeDataItems(List items) {
+ return items.stream()
+ .collect(Collectors.groupingBy(GriDataItem::getCategory));
+ }
+}
\ No newline at end of file