diff --git a/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/DtoDownloadProxyController.java b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/DtoDownloadProxyController.java new file mode 100644 index 00000000..53175d31 --- /dev/null +++ b/dap-gateway/src/main/java/io/shinhanlife/dap/mcg/presentation/DtoDownloadProxyController.java @@ -0,0 +1,72 @@ +package io.shinhanlife.dap.mcg.presentation; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.ContentDisposition; +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.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientResponseException; + +/** + * Gateway 화면에서 들어온 DTO 엑셀 요청을 실제 DTO 클래스가 있는 Tool Pod로 전달한다. + * 브라우저가 동일 출처(8081)만 호출하도록 하여 CORS와 배포 주소 차이를 숨긴다. + */ +@RestController +public class DtoDownloadProxyController { + + private static final MediaType XLSX_MEDIA_TYPE = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + private final RestClient restClient; + private final String toolPodUrl; + + public DtoDownloadProxyController( + RestClient.Builder restClientBuilder, + @Value("${mcp.gateway.fallback.default-url:http://localhost:8084}") String toolPodUrl) { + this.restClient = restClientBuilder.build(); + this.toolPodUrl = toolPodUrl.replaceAll("/+$", ""); + } + + @GetMapping("/dto-download/options") + public List options() { + // 셀렉트박스에 표시할 DTO 이름 목록을 Tool Pod에서 조회한다. + List options = restClient.get() + .uri(toolPodUrl + "/dto-download/options") + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + return options == null ? List.of() : options; + } + + @GetMapping("/dto-download/{dtoName}") + public ResponseEntity download(@PathVariable String dtoName) { + byte[] workbook; + try { + // 생성된 엑셀 바이트를 그대로 브라우저에 전달한다. + workbook = restClient.get() + .uri(toolPodUrl + "/dto-download/{dtoName}", dtoName) + .retrieve() + .body(byte[].class); + } catch (RestClientResponseException error) { + // 형식 불일치 등의 상태 코드와 오류 메시지도 변경 없이 전달한다. + return ResponseEntity.status(error.getStatusCode()) + .contentType(error.getResponseHeaders() != null + && error.getResponseHeaders().getContentType() != null + ? error.getResponseHeaders().getContentType() : MediaType.TEXT_PLAIN) + .body(error.getResponseBodyAsByteArray()); + } + String fileName = dtoName + ".xlsx"; + return ResponseEntity.ok() + .contentType(XLSX_MEDIA_TYPE) + .contentLength(workbook == null ? 0 : workbook.length) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(fileName).build().toString()) + .body(workbook == null ? new byte[0] : workbook); + } +} diff --git a/dap-was-lib/src/main/resources/static/tool-test-console.html b/dap-was-lib/src/main/resources/static/tool-test-console.html index 951eda99..a6ad6a6d 100644 --- a/dap-was-lib/src/main/resources/static/tool-test-console.html +++ b/dap-was-lib/src/main/resources/static/tool-test-console.html @@ -19,6 +19,10 @@ .result { background:#101012; border:1px solid var(--line); border-radius:8px; padding:14px; white-space:pre-wrap; overflow:auto; max-height:520px; font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; } .case-list { display:grid; gap:8px; margin-top:12px; } .case-row { display:flex; align-items:center; gap:8px; padding:9px; border:1px solid var(--line); border-radius:8px; } .case-row main { padding:0; margin:0; flex:1; min-width:0; } .case-row strong,.case-row small { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .case-row small { color:var(--muted); margin-top:3px; } .run-log { margin-top:12px; font:12px/1.55 ui-monospace,monospace; color:var(--muted); white-space:pre-wrap; max-height:220px; overflow:auto; } + .modal-backdrop { position:fixed; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; padding:20px; background:rgba(0,0,0,.68); } + .modal { width:min(420px,100%); padding:20px; background:var(--surface); border:1px solid var(--line); border-radius:12px; box-shadow:0 24px 70px rgba(0,0,0,.5); } + .modal-header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; } .modal-header h2 { margin:0; font-size:17px; } + .modal-close { padding:4px 8px; font-size:18px; line-height:1; } .modal-actions { display:flex; justify-content:flex-end; margin-top:18px; } .footer-note { margin-top:18px; color:#71717a; font-size:12px; } @media (max-width:900px) { .grid { grid-template-columns:1fr; } .header-inner { padding:0 16px; } main { padding:20px 16px; } } @@ -49,6 +53,7 @@
+ Manifest loading v0.0.1
@@ -68,6 +73,19 @@ + - \ No newline at end of file + diff --git a/dap-was-oth/build.gradle b/dap-was-oth/build.gradle index 76871349..f2effdbd 100644 --- a/dap-was-oth/build.gradle +++ b/dap-was-oth/build.gradle @@ -4,6 +4,7 @@ plugins { dependencies { implementation project(':dap-was-lib') + implementation 'org.apache.poi:poi-ooxml:5.3.0' } dependencies { diff --git a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/mci/ncm/d/io/ONCMD0030_O.java b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/mci/ncm/d/io/ONCMD0030_O.java new file mode 100644 index 00000000..82bf8f95 --- /dev/null +++ b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/infra/itrf/mci/ncm/d/io/ONCMD0030_O.java @@ -0,0 +1,68 @@ +package io.shinhanlife.dap.mcc.infra.itrf.mci.ncm.d.io; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import io.shinhanlife.glow.communication.annotation.GlowTrgmField; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * ONCMD0030_O 매핑 DTO + */ +@NoArgsConstructor +@Getter +@Setter +public class ONCMD0030_O { + + @GlowTrgmField(order = 1, description = "고객스마트정보조회OutDto", type = "gm") + private List cstSmartIfinOutDto; + + @GlowTrgmField(order = 1, description = "고객스마트정보조회OutDto2", type = "gm") + private List cstSmartIfinOutDto2; + + @JsonIgnoreProperties(ignoreUnknown = true) + @NoArgsConstructor + @Getter + @Setter + public static class CstSmartIfinOutDto { + + @GlowTrgmField(order = 1, length = 1, description = "동의여부") + private String agrYn; + + @GlowTrgmField(order = 2, length = 12, description = "고객번호") + private String csNo; + + @GlowTrgmField(order = 3, length = 50, description = "주민등록번호") + private String rdreNo; + + @GlowTrgmField(order = 4, length = 20, description = "등록일시") + private String rgiDt; + + } + + + @JsonIgnoreProperties(ignoreUnknown = true) + @NoArgsConstructor + @Getter + @Setter + public static class CstSmartIfinOutDto2 { + + @GlowTrgmField(order = 1, length = 1, description = "동의여부") + private String agrYnaa; + + @GlowTrgmField(order = 2, length = 12, description = "고객번호") + private String csNoaa; + + @GlowTrgmField(order = 3, length = 50, description = "주민등록번호") + private String rdreNoaa; + + @GlowTrgmField(order = 4, length = 20, description = "등록일시") + private String rgiDtaa; + + } + +} diff --git a/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/presentation/DtoExcelDownloadController.java b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/presentation/DtoExcelDownloadController.java new file mode 100644 index 00000000..4ffe7657 --- /dev/null +++ b/dap-was-oth/src/main/java/io/shinhanlife/dap/mcc/presentation/DtoExcelDownloadController.java @@ -0,0 +1,424 @@ +package io.shinhanlife.dap.mcc.presentation; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Pattern; + +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFColor; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.RegexPatternTypeFilter; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * MCI DTO 클래스를 조회하고 인터페이스 설계서 형식의 엑셀 파일을 생성한다. + * 외부 템플릿 파일에 의존하지 않고 Apache POI로 양식과 데이터를 모두 만든다. + */ +@RestController +public class DtoExcelDownloadController { + + private static final int FIRST_FIELD_ROW = 12; + private static final int TEMPLATE_LAST_ROW = 35; + private static final int COLUMN_COUNT = 20; + private static final MediaType XLSX_MEDIA_TYPE = MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + private static final String MCI_BASE_PACKAGE = "io.shinhanlife.dap.mcc.infra.itrf.mci"; + private static final String INVALID_DTO_MESSAGE = + "dto의 내용이 엑셀파일 양식에 맞지않습니다 파일을 확인해주세요"; + + private final Map dtoClasses; + + public DtoExcelDownloadController() { + this.dtoClasses = scanDtoClasses(); + } + + @GetMapping("/dto-download/options") + public List options() { + // 클래스패스에서 자동 검색된 DTO 목록을 화면에 제공한다. + return List.copyOf(dtoClasses.keySet()); + } + + @GetMapping("/dto-download/{dtoName}") + public ResponseEntity download(@PathVariable String dtoName) throws IOException { + // 스캔되지 않은 이름을 받아 임의 클래스를 조회하지 못하도록 제한한다. + String className = dtoClasses.get(dtoName); + if (className == null) { + return ResponseEntity.notFound().build(); + } + byte[] workbook = createWorkbook(dtoName, className); + String fileName = dtoName + ".xlsx"; + return ResponseEntity.ok() + .contentType(XLSX_MEDIA_TYPE) + .contentLength(workbook.length) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(fileName).build().toString()) + .body(workbook); + } + + @ExceptionHandler(DtoFormatException.class) + public ResponseEntity handleInvalidDto() { + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .contentType(MediaType.parseMediaType("text/plain;charset=UTF-8")) + .body(INVALID_DTO_MESSAGE); + } + + private byte[] createWorkbook(String dtoName, String className) throws IOException { + // 요청마다 새 워크북을 생성하므로 여러 사용자의 다운로드가 서로 영향을 주지 않는다. + try (XSSFWorkbook workbook = createTemplateWorkbook(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + Sheet sheet = workbook.getSheetAt(0); + setText(sheet, 2, 2, dtoName); + setText(sheet, 3, 2, dtoName); + + List fields; + try { + fields = describeFields(Class.forName(className)); + } catch (ReflectiveOperationException error) { + throw new IOException("DTO class could not be inspected: " + className, error); + } + writeFields(sheet, fields); + workbook.write(output); + return output.toByteArray(); + } + } + + private XSSFWorkbook createTemplateWorkbook() { + // 기준 문서의 시트명, 열 너비, 병합, 색상과 테두리를 코드로 재현한다. + XSSFWorkbook workbook = new XSSFWorkbook(); + Sheet sheet = workbook.createSheet("대내"); + sheet.setDisplayGridlines(false); + sheet.createFreezePane(0, 12); + sheet.getPrintSetup().setLandscape(true); + sheet.setRepeatingRows(new CellRangeAddress(11, 11, -1, -1)); + + double[] widths = {4.44, 8, 23.22, 23.22, 10, 14, 18, 11, 9, 7.44, + 8, 9.55, 9, 10.55, 11.44, 9, 13, 10.55, 14, 30}; + for (int column = 0; column < widths.length; column++) { + sheet.setColumnWidth(column, (int) (widths[column] * 256)); + } + + CellStyle titleStyle = style(workbook, "000000", "FFFFFF", true, (short) 14, + HorizontalAlignment.CENTER, false); + CellStyle sectionStyle = style(workbook, "F2F2F2", "000000", true, (short) 10, + HorizontalAlignment.CENTER, false); + CellStyle labelStyle = borderedStyle(workbook, "F2F2F2", true, HorizontalAlignment.CENTER); + CellStyle inputStyle = borderedStyle(workbook, "C6D9F1", false, HorizontalAlignment.LEFT); + CellStyle requiredStyle = borderedStyle(workbook, "C6D9F1", false, HorizontalAlignment.LEFT); + CellStyle autoStyle = borderedStyle(workbook, "F2DCDB", false, HorizontalAlignment.LEFT); + CellStyle userStyle = borderedStyle(workbook, "FFFFFF", false, HorizontalAlignment.LEFT); + CellStyle headerStyle = borderedStyle(workbook, "D9D9D9", true, HorizontalAlignment.CENTER); + headerStyle.setWrapText(true); + CellStyle whiteDataStyle = borderedStyle(workbook, "FFFFFF", false, HorizontalAlignment.CENTER); + CellStyle blueDataStyle = borderedStyle(workbook, "C6D9F1", false, HorizontalAlignment.CENTER); + CellStyle pinkDataStyle = borderedStyle(workbook, "F2DCDB", false, HorizontalAlignment.CENTER); + + createStyledRow(sheet, 0, 25.5f, titleStyle); + merge(sheet, "A1:T1"); + setText(sheet, 0, 0, "인터페이스 설계서(대내)"); + + createStyledRow(sheet, 1, 18f, sectionStyle); + merge(sheet, "A2:T2"); + setText(sheet, 1, 0, "기본정보"); + + String[] labels = {"코드", "한글명", "영문명", "암호화", "유형", "레코드구분자", "필드구분자"}; + for (int index = 0; index < labels.length; index++) { + int rowIndex = index + 2; + Row row = sheet.createRow(rowIndex); + cell(row, 1, labelStyle).setCellValue(labels[index]); + cell(row, 2, inputStyle); + cell(row, 3, inputStyle); + merge(sheet, "C" + (rowIndex + 1) + ":D" + (rowIndex + 1)); + } + setText(sheet, 6, 2, "json"); + sheet.getRow(7).setHeightInPoints(24); + sheet.getRow(8).setHeightInPoints(24); + + for (int rowIndex = 3; rowIndex <= 5; rowIndex++) { + Row row = sheet.getRow(rowIndex); + CellStyle legendStyle = rowIndex == 3 ? requiredStyle : rowIndex == 4 ? autoStyle : userStyle; + cell(row, 5, legendStyle); + cell(row, 6, legendStyle); + merge(sheet, "F" + (rowIndex + 1) + ":G" + (rowIndex + 1)); + } + setText(sheet, 3, 7, "필수입력"); + setText(sheet, 4, 7, "필드자동채우기(메타시스템 연동시)"); + setText(sheet, 5, 7, "사용자입력(필요시)"); + + createStyledRow(sheet, 10, 18f, sectionStyle); + merge(sheet, "A11:T11"); + setText(sheet, 10, 0, "필드정보"); + + String[] headers = {"NO", "Level", "한글명", "부모식별자(한글명)", "끝수여부", "영문명", + "부모식별자(영문명)", "데이터유형", "필드길이", "SCALE", "기본값", "정렬기준", + "채움문자", "암호화방식", "메타체크여부", "한글여부", "소수점포함여부", + "마스킹여부", "마스킹패턴코드", "비고"}; + Row header = sheet.createRow(11); + header.setHeightInPoints(30); + for (int column = 0; column < headers.length; column++) { + cell(header, column, headerStyle).setCellValue(headers[column]); + } + + for (int rowIndex = FIRST_FIELD_ROW; rowIndex <= TEMPLATE_LAST_ROW; rowIndex++) { + Row row = sheet.createRow(rowIndex); + row.setHeightInPoints(15.75f); + for (int column = 0; column < COLUMN_COUNT; column++) { + CellStyle dataStyle; + if (column == 0 || column == 4 || (column >= 14 && column <= 16) || column == 19) { + dataStyle = whiteDataStyle; + } else if (column >= 1 && column <= 3) { + dataStyle = blueDataStyle; + } else { + dataStyle = pinkDataStyle; + } + cell(row, column, dataStyle); + } + } + return workbook; + } + + private CellStyle style(XSSFWorkbook workbook, String fillColor, String fontColor, + boolean bold, short fontSize, HorizontalAlignment alignment, + boolean bordered) { + CellStyle style = workbook.createCellStyle(); + style.setAlignment(alignment); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setFillForegroundColor(new XSSFColor(java.awt.Color.decode("#" + fillColor), null)); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setFontName("맑은 고딕"); + font.setFontHeightInPoints(fontSize); + font.setBold(bold); + font.setColor("FFFFFF".equals(fontColor) ? IndexedColors.WHITE.getIndex() : IndexedColors.BLACK.getIndex()); + style.setFont(font); + if (bordered) setBorders(style); + return style; + } + + private CellStyle borderedStyle(XSSFWorkbook workbook, String fillColor, + boolean bold, HorizontalAlignment alignment) { + return style(workbook, fillColor, "000000", bold, (short) 9, alignment, true); + } + + private void setBorders(CellStyle style) { + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setTopBorderColor(IndexedColors.GREY_50_PERCENT.getIndex()); + style.setBottomBorderColor(IndexedColors.GREY_50_PERCENT.getIndex()); + style.setLeftBorderColor(IndexedColors.GREY_50_PERCENT.getIndex()); + style.setRightBorderColor(IndexedColors.GREY_50_PERCENT.getIndex()); + } + + private void createStyledRow(Sheet sheet, int rowIndex, float height, CellStyle style) { + Row row = sheet.createRow(rowIndex); + row.setHeightInPoints(height); + for (int column = 0; column < COLUMN_COUNT; column++) cell(row, column, style); + } + + private Cell cell(Row row, int column, CellStyle style) { + Cell cell = row.createCell(column); + cell.setCellStyle(style); + return cell; + } + + private void merge(Sheet sheet, String range) { + sheet.addMergedRegion(CellRangeAddress.valueOf(range)); + } + + private void writeFields(Sheet sheet, List fields) { + // 기본 24행을 유지하고 필드가 더 많으면 마지막 행의 서식을 복제해 확장한다. + int requiredRows = Math.max(fields.size(), TEMPLATE_LAST_ROW - FIRST_FIELD_ROW + 1); + for (int offset = 0; offset < requiredRows; offset++) { + int rowIndex = FIRST_FIELD_ROW + offset; + Row row = sheet.getRow(rowIndex); + if (row == null) { + row = cloneTemplateRow(sheet, rowIndex); + } + clearRowValues(row); + setNumber(row, 0, offset + 1); + if (offset < fields.size()) { + FieldRow field = fields.get(offset); + setNumber(row, 1, field.level()); + setText(row, 2, field.description()); + setText(row, 3, field.parentDescription()); + setText(row, 5, field.name()); + setText(row, 6, field.parentName()); + setText(row, 7, field.dataType()); + if (field.length() > 0) { + setNumber(row, 8, field.length()); + } + } + } + } + + private Row cloneTemplateRow(Sheet sheet, int rowIndex) { + Row source = sheet.getRow(TEMPLATE_LAST_ROW); + Row target = sheet.createRow(rowIndex); + target.setHeight(source.getHeight()); + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell sourceCell = source.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK); + Cell targetCell = target.createCell(column); + CellStyle style = sourceCell.getCellStyle(); + targetCell.setCellStyle(style); + } + return target; + } + + private void clearRowValues(Row row) { + for (int column = 0; column < COLUMN_COUNT; column++) { + Cell cell = row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK); + cell.setBlank(); + } + } + + private List describeFields(Class rootClass) { + List output = new ArrayList<>(); + appendFields(rootClass, 1, "", "", output); + return output; + } + + private void appendFields(Class type, int level, String parentName, + String parentDescription, List output) { + // 중첩 DTO와 List 요소 타입을 재귀적으로 펼쳐 Level 및 부모 식별자를 계산한다. + List fields = new ArrayList<>(List.of(type.getDeclaredFields())); + fields.removeIf(field -> field.isSynthetic()); + fields.sort(Comparator.comparingInt(this::fieldOrder)); + for (Field field : fields) { + Annotation metadata = telegramMetadata(field); + // 한글명, 순서, 길이를 알 수 없는 DTO는 설계서 양식으로 변환할 수 없다. + if (metadata == null) { + throw new DtoFormatException(); + } + String description = annotationString(metadata, "description", field.getName()); + int length = annotationInt(metadata, "length", 0); + Class nestedType = nestedType(field); + String dataType = annotationString(metadata, "type", simpleDataType(field)); + output.add(new FieldRow(level, description, parentDescription, field.getName(), + parentName, dataType, length)); + if (nestedType != null && nestedType != type) { + appendFields(nestedType, level + 1, field.getName(), description, output); + } + } + } + + private int fieldOrder(Field field) { + return annotationInt(telegramMetadata(field), "order", Integer.MAX_VALUE); + } + + private Annotation telegramMetadata(Field field) { + for (Annotation annotation : field.getDeclaredAnnotations()) { + if (annotation.annotationType().getSimpleName().equals("GlowTrgmField")) { + return annotation; + } + } + return null; + } + + private String annotationString(Annotation annotation, String methodName, String fallback) { + Object value = annotationValue(annotation, methodName); + return value instanceof String text && !text.isBlank() ? text : fallback; + } + + private int annotationInt(Annotation annotation, String methodName, int fallback) { + Object value = annotationValue(annotation, methodName); + return value instanceof Number number ? number.intValue() : fallback; + } + + private Object annotationValue(Annotation annotation, String methodName) { + if (annotation == null) { + return null; + } + try { + Method method = annotation.annotationType().getMethod(methodName); + return method.invoke(annotation); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private Class nestedType(Field field) { + Class type = field.getType(); + if (List.class.isAssignableFrom(type) && field.getGenericType() instanceof ParameterizedType generic) { + Type argument = generic.getActualTypeArguments()[0]; + if (argument instanceof Class itemType && isDtoType(itemType)) { + return itemType; + } + } + return isDtoType(type) ? type : null; + } + + private boolean isDtoType(Class type) { + return !type.isPrimitive() + && !type.getName().startsWith("java.") + && !type.isEnum(); + } + + private String simpleDataType(Field field) { + if (List.class.isAssignableFrom(field.getType())) { + return "List"; + } + return field.getType().getSimpleName(); + } + + private void setText(Sheet sheet, int row, int column, String value) { + setText(sheet.getRow(row), column, value); + } + + private void setText(Row row, int column, String value) { + row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK).setCellValue(value == null ? "" : value); + } + + private void setNumber(Row row, int column, int value) { + row.getCell(column, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK).setCellValue(value); + } + + private Map scanDtoClasses() { + // itrf.mci 하위의 모든 io 패키지를 검색하므로 신규 DTO 추가 시 하드코딩이 필요 없다. + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new RegexPatternTypeFilter( + Pattern.compile(".*\\.itrf\\.mci\\..*\\.io\\.[^.]+$"))); + + Map classes = new TreeMap<>(); + scanner.findCandidateComponents(MCI_BASE_PACKAGE).forEach(candidate -> { + String className = candidate.getBeanClassName(); + if (className == null || className.contains("$")) { + return; + } + String simpleName = className.substring(className.lastIndexOf('.') + 1); + String previous = classes.putIfAbsent(simpleName, className); + if (previous != null) { + throw new IllegalStateException("Duplicate DTO class name: " + simpleName); + } + }); + return Map.copyOf(classes); + } + + private record FieldRow(int level, String description, String parentDescription, + String name, String parentName, String dataType, int length) { + } + + private static final class DtoFormatException extends RuntimeException { + } +}