feat: add Excel document generator
This commit is contained in:
@@ -10,6 +10,9 @@ dependencies {
|
||||
// Gateway REST API와 관리 화면의 HTTP 요청을 처리합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
|
||||
// 등록된 Excel 양식을 보존하면서 Tool 문서를 생성합니다.
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// Tool Registry 및 분산 캐시 연동에 사용합니다.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
|
||||
public record DocumentGenerationRequest(
|
||||
ToolMetadata tool,
|
||||
String version,
|
||||
boolean includeProgram,
|
||||
boolean includeProcess,
|
||||
boolean includeRevision) {
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.CacheControl;
|
||||
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.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/mcp/api/v1/admin/documents")
|
||||
public class DocumentGeneratorController {
|
||||
|
||||
private static final MediaType XLSX = MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
|
||||
private final DocumentGeneratorService documentGeneratorService;
|
||||
|
||||
public DocumentGeneratorController(DocumentGeneratorService documentGeneratorService) {
|
||||
this.documentGeneratorService = documentGeneratorService;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/program", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> generateProgram(@RequestBody DocumentGenerationRequest request) {
|
||||
return download(documentGeneratorService.generateProgram(request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/interface", produces = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
public ResponseEntity<byte[]> generateInterface(@RequestBody DocumentGenerationRequest request) {
|
||||
return download(documentGeneratorService.generateInterface(request));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> invalidRequest(IllegalArgumentException exception) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", exception.getMessage()));
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> download(GeneratedDocument document) {
|
||||
ContentDisposition disposition = ContentDisposition.attachment()
|
||||
.filename(document.fileName(), StandardCharsets.UTF_8)
|
||||
.build();
|
||||
return ResponseEntity.ok()
|
||||
.contentType(XLSX)
|
||||
.contentLength(document.content().length)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
|
||||
.body(document.content());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.ss.util.CellReference;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class DocumentGeneratorService {
|
||||
|
||||
static final String PROGRAM_TEMPLATE = "document-templates/excel/program-definition-template.xlsx";
|
||||
static final String INTERFACE_TEMPLATE = "document-templates/excel/interface-definition-template.xlsx";
|
||||
|
||||
private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.BASIC_ISO_DATE;
|
||||
private static final DateTimeFormatter DISPLAY_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
private static final Pattern PATTERN_LENGTH = Pattern.compile("\\\\d\\{(\\d+)}");
|
||||
private static final int INTERFACE_FIRST_ROW = 8;
|
||||
private static final int INTERFACE_LAST_ROW = 30;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public DocumentGeneratorService(ObjectMapper objectMapper) {
|
||||
this(objectMapper, Clock.system(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
|
||||
DocumentGeneratorService(ObjectMapper objectMapper, Clock clock) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public GeneratedDocument generateProgram(DocumentGenerationRequest request) {
|
||||
ToolMetadata tool = validate(request);
|
||||
if (!request.includeProgram() && !request.includeProcess() && !request.includeRevision()) {
|
||||
throw new IllegalArgumentException("프로그램 문서에서 한 개 이상의 시트를 선택하세요.");
|
||||
}
|
||||
|
||||
String version = normalizeVersion(request.version());
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
try (InputStream input = resource(PROGRAM_TEMPLATE);
|
||||
Workbook workbook = WorkbookFactory.create(input)) {
|
||||
|
||||
Sheet programSheet = requiredSheet(workbook, "프로그램정의서");
|
||||
Sheet designTemplate = requiredSheet(workbook, "입출력정의");
|
||||
Sheet revisionSheet = request.includeRevision()
|
||||
? workbook.cloneSheet(workbook.getSheetIndex(designTemplate))
|
||||
: null;
|
||||
|
||||
if (request.includeProgram()) {
|
||||
populateProgramSheet(programSheet, tool, version, today);
|
||||
} else {
|
||||
workbook.removeSheetAt(workbook.getSheetIndex(programSheet));
|
||||
}
|
||||
|
||||
if (request.includeProcess()) {
|
||||
workbook.setSheetName(workbook.getSheetIndex(designTemplate), "처리설계");
|
||||
populateProcessSheet(designTemplate, tool);
|
||||
} else {
|
||||
workbook.removeSheetAt(workbook.getSheetIndex(designTemplate));
|
||||
}
|
||||
|
||||
if (revisionSheet != null) {
|
||||
workbook.setSheetName(workbook.getSheetIndex(revisionSheet), "개정이력");
|
||||
populateRevisionSheet(revisionSheet, version, today);
|
||||
}
|
||||
|
||||
workbook.setActiveSheet(0);
|
||||
return output(workbook, programFileName(tool, version, today));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("프로그램정의서 Excel 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public GeneratedDocument generateInterface(DocumentGenerationRequest request) {
|
||||
ToolMetadata tool = validate(request);
|
||||
String version = normalizeVersion(request.version());
|
||||
LocalDate today = LocalDate.now(clock);
|
||||
try (InputStream input = resource(INTERFACE_TEMPLATE);
|
||||
Workbook workbook = WorkbookFactory.create(input)) {
|
||||
|
||||
populateInterfaceSheet(requiredSheet(workbook, "Request In"), tool, false);
|
||||
populateInterfaceSheet(requiredSheet(workbook, "Response Out"), tool, true);
|
||||
workbook.setActiveSheet(0);
|
||||
return output(workbook, interfaceFileName(tool, version, today));
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("인터페이스정의서 Excel 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void populateProgramSheet(Sheet sheet, ToolMetadata tool, String version, LocalDate today) {
|
||||
String label = label(tool);
|
||||
set(sheet, "B4", documentId(tool));
|
||||
set(sheet, "E4", version);
|
||||
set(sheet, "H4", DISPLAY_DATE.format(today));
|
||||
set(sheet, "B5", label);
|
||||
set(sheet, "E5", value(tool.getName()));
|
||||
set(sheet, "H5", "생성 완료");
|
||||
set(sheet, "B6", value(tool.getCategoryKey()));
|
||||
set(sheet, "E6", "AX HUB MCP Gateway");
|
||||
set(sheet, "H6", "자동 생성");
|
||||
set(sheet, "B9", defaultValue(tool.getDescription(), "설명이 등록되지 않은 Tool입니다."));
|
||||
set(sheet, "B10", label);
|
||||
set(sheet, "E10", tool.getOperationType() == null ? "-" : tool.getOperationType().name());
|
||||
set(sheet, "H10", Boolean.FALSE.equals(tool.getVisible()) ? "비공개" : "공개");
|
||||
set(sheet, "B11", schemaFields(tool.getParametersSchema()).size()
|
||||
+ "개 파라미터가 Tool JSON 스키마에서 자동 매핑되었습니다.");
|
||||
set(sheet, "B14", value(tool.getCategoryKey()));
|
||||
set(sheet, "E14", value(tool.getPodUrl()));
|
||||
set(sheet, "H14", value(tool.getIntegrationType()));
|
||||
set(sheet, "B15", value(tool.getEndpoint()));
|
||||
set(sheet, "E15", value(tool.getMciServiceId()));
|
||||
set(sheet, "H15", formatTimeout(tool.getTimeoutMillis()));
|
||||
set(sheet, "B16", yesNo(tool.getRequiresApproval()));
|
||||
set(sheet, "E16", yesNo(tool.getReadOnlyHint()));
|
||||
set(sheet, "H16", yesNo(tool.getIdempotentHint()));
|
||||
set(sheet, "B21", label);
|
||||
set(sheet, "C21", "Tool 입력 스키마에 따라 요청 파라미터를 검증합니다.");
|
||||
set(sheet, "B22", defaultValue(tool.getIntegrationType(), "REST"));
|
||||
set(sheet, "H22", value(tool.getMciServiceId()));
|
||||
set(sheet, "A26", "※ ToolMetadata를 기준으로 자동 생성된 문서입니다. 업무 규칙과 승인 정보는 담당자 검토가 필요합니다.");
|
||||
}
|
||||
|
||||
private void populateProcessSheet(Sheet sheet, ToolMetadata tool) {
|
||||
CellStyle title = style(sheet, "A1");
|
||||
CellStyle section = style(sheet, "A2");
|
||||
CellStyle header = style(sheet, "A3");
|
||||
CellStyle data = style(sheet, "A4");
|
||||
CellStyle note = style(sheet, "A9");
|
||||
resetSheet(sheet, 9, 9);
|
||||
|
||||
mergeSet(sheet, "A1:I1", "처리설계", title);
|
||||
mergeSet(sheet, "A2:I2", label(tool) + " · 공통 처리 절차", section);
|
||||
setStyled(sheet, 2, 0, "단계", header);
|
||||
setStyled(sheet, 2, 1, "처리 주체", header);
|
||||
mergeSet(sheet, "C3:F3", "처리 내용", header);
|
||||
mergeSet(sheet, "G3:H3", "성공 조건", header);
|
||||
setStyled(sheet, 2, 8, "비고", header);
|
||||
|
||||
List<List<String>> steps = List.of(
|
||||
List.of("1", "Gateway", "호출자 인증과 Tool 실행 권한을 확인합니다.", "권한 검증 성공", "공통 처리"),
|
||||
List.of("2", label(tool), "Tool 입력 스키마에 따라 요청 파라미터를 검증합니다.", "스키마 검증 성공", "자동 생성"),
|
||||
List.of("3", defaultValue(tool.getIntegrationType(), "REST"), "등록된 엔드포인트 또는 서비스 ID로 대상 시스템을 호출합니다.", "정상 응답 수신", value(tool.getMciServiceId())),
|
||||
List.of("4", "Gateway", "응답을 MCP 표준 결과로 변환하고 정책을 검사합니다.", "응답 정책 통과", "응답 정책"),
|
||||
List.of("5", "Gateway", "감사 로그를 기록하고 호출자에게 결과를 반환합니다.", "응답 전송 완료", "추적 ID 포함")
|
||||
);
|
||||
for (int index = 0; index < steps.size(); index++) {
|
||||
int row = 3 + index;
|
||||
List<String> step = steps.get(index);
|
||||
setStyled(sheet, row, 0, step.get(0), data);
|
||||
setStyled(sheet, row, 1, step.get(1), data);
|
||||
mergeSet(sheet, "C" + (row + 1) + ":F" + (row + 1), step.get(2), data);
|
||||
mergeSet(sheet, "G" + (row + 1) + ":H" + (row + 1), step.get(3), data);
|
||||
setStyled(sheet, row, 8, step.get(4), data);
|
||||
sheet.getRow(row).setHeightInPoints(38);
|
||||
}
|
||||
mergeSet(sheet, "A9:I9", "※ 공통 처리 흐름은 ToolMetadata와 Gateway 정책을 기준으로 자동 작성되었습니다.", note);
|
||||
}
|
||||
|
||||
private void populateRevisionSheet(Sheet sheet, String version, LocalDate today) {
|
||||
CellStyle title = style(sheet, "A1");
|
||||
CellStyle section = style(sheet, "A2");
|
||||
CellStyle header = style(sheet, "A3");
|
||||
CellStyle data = style(sheet, "A4");
|
||||
CellStyle note = style(sheet, "A9");
|
||||
resetSheet(sheet, 9, 9);
|
||||
|
||||
mergeSet(sheet, "A1:I1", "개정이력", title);
|
||||
mergeSet(sheet, "A2:I2", "문서 버전 및 변경 내역", section);
|
||||
setStyled(sheet, 2, 0, "버전", header);
|
||||
setStyled(sheet, 2, 1, "작성일", header);
|
||||
mergeSet(sheet, "C3:D3", "작성자", header);
|
||||
mergeSet(sheet, "E3:H3", "변경 내용", header);
|
||||
setStyled(sheet, 2, 8, "비고", header);
|
||||
|
||||
setStyled(sheet, 3, 0, version, data);
|
||||
setStyled(sheet, 3, 1, DISPLAY_DATE.format(today), data);
|
||||
mergeSet(sheet, "C4:D4", "Document Generator", data);
|
||||
mergeSet(sheet, "E4:H4", "ToolMetadata 기준 최초 생성", data);
|
||||
setStyled(sheet, 3, 8, "자동 생성", data);
|
||||
mergeSet(sheet, "A6:I6", "※ 배포 전 담당자의 최종 검토가 필요합니다.", note);
|
||||
}
|
||||
|
||||
private void populateInterfaceSheet(Sheet sheet, ToolMetadata tool, boolean response) {
|
||||
String label = label(tool);
|
||||
String interfaceId = interfaceId(tool);
|
||||
set(sheet, "A1", label + " 인터페이스 설계서");
|
||||
set(sheet, "C2", label);
|
||||
set(sheet, "C3", defaultValue(tool.getDescription(), "설명이 등록되지 않은 Tool입니다."));
|
||||
set(sheet, "D4", value(tool.getEndpoint()));
|
||||
set(sheet, "D5", "운영 URL 확인 필요");
|
||||
set(sheet, "A7", interfaceId);
|
||||
set(sheet, "B7", response ? "데이터 수신시스템 · 응답 (Response Out)" : "데이터 송신시스템 · 요청 (Request In)");
|
||||
|
||||
clearInterfaceRows(sheet);
|
||||
if (response) {
|
||||
writeInterfaceField(sheet, INTERFACE_FIRST_ROW, interfaceId, "AX HUB\nMCP Gateway", "Body",
|
||||
interfaceId + "_O", new SchemaField("resultData", "object", "결과 데이터", "-", true, "", false));
|
||||
set(sheet, "C33", prettyJson(Map.of("resultData", Map.of("status", "SUCCESS"))));
|
||||
} else {
|
||||
List<SchemaField> fields = schemaFields(tool.getParametersSchema());
|
||||
if (fields.size() > INTERFACE_LAST_ROW - INTERFACE_FIRST_ROW + 1) {
|
||||
throw new IllegalArgumentException("인터페이스 양식은 최대 23개 요청 필드를 지원합니다.");
|
||||
}
|
||||
for (int index = 0; index < fields.size(); index++) {
|
||||
writeInterfaceField(sheet, INTERFACE_FIRST_ROW + index, interfaceId,
|
||||
defaultValue(tool.getIntegrationType(), "Tool"), "Body", interfaceId + "_I", fields.get(index));
|
||||
}
|
||||
set(sheet, "C33", prettyJson(exampleFromSchema(tool.getParametersSchema())));
|
||||
}
|
||||
set(sheet, "A34", "※ ToolMetadata를 기준으로 자동 생성된 검토용 문서입니다.");
|
||||
}
|
||||
|
||||
private void writeInterfaceField(Sheet sheet, int rowIndex, String interfaceId, String system, String level,
|
||||
String store, SchemaField field) {
|
||||
if (rowIndex == INTERFACE_FIRST_ROW) {
|
||||
setStyled(sheet, rowIndex, 0, interfaceId, style(sheet, "A9"));
|
||||
setStyled(sheet, rowIndex, 1, system, style(sheet, "B9"));
|
||||
setStyled(sheet, rowIndex, 2, level, style(sheet, "C9"));
|
||||
}
|
||||
setStyled(sheet, rowIndex, 3, store, style(sheet, "D9"));
|
||||
setStyled(sheet, rowIndex, 4, defaultValue(field.description(), field.path()), style(sheet, "E9"));
|
||||
setStyled(sheet, rowIndex, 5, field.path(), style(sheet, "F9"));
|
||||
setStyled(sheet, rowIndex, 6, field.type(), style(sheet, "G9"));
|
||||
setStyled(sheet, rowIndex, 7, field.length(), style(sheet, "H9"));
|
||||
setStyled(sheet, rowIndex, 8, "", style(sheet, "I9"));
|
||||
setStyled(sheet, rowIndex, 9, field.coded() ? "Y" : "N", style(sheet, "J9"));
|
||||
String note = (field.required() ? "필수 · " : "") + "ToolMetadata";
|
||||
if (!field.example().isBlank()) {
|
||||
note += " · 예시: " + field.example();
|
||||
}
|
||||
setStyled(sheet, rowIndex, 10, note, style(sheet, "K9"));
|
||||
}
|
||||
|
||||
private void clearInterfaceRows(Sheet sheet) {
|
||||
for (int rowIndex = INTERFACE_FIRST_ROW; rowIndex <= INTERFACE_LAST_ROW; rowIndex++) {
|
||||
for (int column = 3; column <= 10; column++) {
|
||||
setStyled(sheet, rowIndex, column, "", style(sheet, "D9"));
|
||||
}
|
||||
}
|
||||
set(sheet, "A9", "");
|
||||
set(sheet, "B9", "");
|
||||
set(sheet, "C9", "");
|
||||
}
|
||||
|
||||
private List<SchemaField> schemaFields(Map<String, Object> schema) {
|
||||
List<SchemaField> fields = new ArrayList<>();
|
||||
collectSchemaFields(schema, "", Set.of(), fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void collectSchemaFields(Map<String, Object> schema, String prefix, Set<String> inheritedRequired,
|
||||
List<SchemaField> fields) {
|
||||
if (schema == null) {
|
||||
return;
|
||||
}
|
||||
Object requiredValue = schema.get("required");
|
||||
Set<String> required = requiredValue instanceof Collection<?> values
|
||||
? values.stream().map(String::valueOf).collect(java.util.stream.Collectors.toSet())
|
||||
: inheritedRequired;
|
||||
Object propertiesValue = schema.get("properties");
|
||||
if (!(propertiesValue instanceof Map<?, ?> properties)) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : properties.entrySet()) {
|
||||
String name = String.valueOf(entry.getKey());
|
||||
if (!(entry.getValue() instanceof Map<?, ?> rawNode)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> node = (Map<String, Object>) rawNode;
|
||||
String path = prefix.isBlank() ? name : prefix + "." + name;
|
||||
String type = String.valueOf(node.getOrDefault("type", "string"));
|
||||
String description = value(node.get("description"));
|
||||
String length = schemaLength(node);
|
||||
String example = schemaExample(node);
|
||||
boolean coded = node.get("enum") instanceof Collection<?> values && !values.isEmpty();
|
||||
fields.add(new SchemaField(path, type, description, length, required.contains(name), example, coded));
|
||||
if ("object".equals(type)) {
|
||||
collectSchemaFields(node, path, Set.of(), fields);
|
||||
} else if ("array".equals(type) && node.get("items") instanceof Map<?, ?> items) {
|
||||
collectSchemaFields((Map<String, Object>) items, path + "[]", Set.of(), fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String schemaLength(Map<String, Object> node) {
|
||||
Object length = node.get("maxLength");
|
||||
if (length == null) {
|
||||
length = node.get("length");
|
||||
}
|
||||
if (length != null) {
|
||||
return String.valueOf(length);
|
||||
}
|
||||
Object pattern = node.get("pattern");
|
||||
if (pattern != null) {
|
||||
Matcher matcher = PATTERN_LENGTH.matcher(String.valueOf(pattern));
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
private String schemaExample(Map<String, Object> node) {
|
||||
Object example = node.get("example");
|
||||
if (example == null && node.get("examples") instanceof List<?> examples && !examples.isEmpty()) {
|
||||
example = examples.get(0);
|
||||
}
|
||||
if (example == null) {
|
||||
example = node.get("default");
|
||||
}
|
||||
return value(example);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object exampleFromSchema(Map<String, Object> schema) {
|
||||
if (schema == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Object type = schema.get("type");
|
||||
if ("object".equals(type) || schema.get("properties") instanceof Map<?, ?>) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
Object propertiesValue = schema.get("properties");
|
||||
if (propertiesValue instanceof Map<?, ?> properties) {
|
||||
for (Map.Entry<?, ?> entry : properties.entrySet()) {
|
||||
if (entry.getValue() instanceof Map<?, ?> node) {
|
||||
result.put(String.valueOf(entry.getKey()), exampleFromSchema((Map<String, Object>) node));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Object example = schema.get("example");
|
||||
if (example == null && schema.get("examples") instanceof List<?> examples && !examples.isEmpty()) {
|
||||
example = examples.get(0);
|
||||
}
|
||||
if (example == null) {
|
||||
example = schema.get("default");
|
||||
}
|
||||
if (example != null) {
|
||||
return example;
|
||||
}
|
||||
return switch (String.valueOf(type)) {
|
||||
case "integer", "number" -> 0;
|
||||
case "boolean" -> false;
|
||||
case "array" -> schema.get("items") instanceof Map<?, ?> items
|
||||
? List.of(exampleFromSchema((Map<String, Object>) items)) : List.of();
|
||||
default -> "<value>";
|
||||
};
|
||||
}
|
||||
|
||||
private String prettyJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("JSON 예시 생성에 실패했습니다.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private GeneratedDocument output(Workbook workbook, String fileName) throws IOException {
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
workbook.write(output);
|
||||
return new GeneratedDocument(fileName, output.toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream resource(String path) throws IOException {
|
||||
return new ClassPathResource(path).getInputStream();
|
||||
}
|
||||
|
||||
private ToolMetadata validate(DocumentGenerationRequest request) {
|
||||
if (request == null || request.tool() == null) {
|
||||
throw new IllegalArgumentException("ToolMetadata가 필요합니다.");
|
||||
}
|
||||
ToolMetadata tool = request.tool();
|
||||
if (isBlank(tool.getUid()) && isBlank(tool.getName())) {
|
||||
throw new IllegalArgumentException("Tool UID 또는 Tool 이름이 필요합니다.");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
private String programFileName(ToolMetadata tool, String version, LocalDate today) {
|
||||
return safeFilename(label(tool)) + "_프로그램정의서_v" + version + "_" + FILE_DATE.format(today) + ".xlsx";
|
||||
}
|
||||
|
||||
private String interfaceFileName(ToolMetadata tool, String version, LocalDate today) {
|
||||
return safeFilename(label(tool)) + "_인터페이스정의서_v" + version + "_" + FILE_DATE.format(today) + ".xlsx";
|
||||
}
|
||||
|
||||
private String normalizeVersion(String version) {
|
||||
String normalized = isBlank(version) ? "1.0" : version.trim().replaceFirst("^[vV]", "");
|
||||
if (!normalized.matches("[0-9A-Za-z._-]+")) {
|
||||
throw new IllegalArgumentException("버전은 영문, 숫자, 점, 밑줄, 하이픈만 사용할 수 있습니다.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String safeFilename(String value) {
|
||||
String result = defaultValue(value, "tool").replaceAll("[\\\\/:*?\"<>|]", "_").trim();
|
||||
return result.isEmpty() ? "tool" : result;
|
||||
}
|
||||
|
||||
private String label(ToolMetadata tool) {
|
||||
return defaultValue(tool.getDisplayName(), defaultValue(tool.getName(), tool.getUid()));
|
||||
}
|
||||
|
||||
private String documentId(ToolMetadata tool) {
|
||||
String category = defaultValue(tool.getCategoryKey(), "ETC").toUpperCase(Locale.ROOT);
|
||||
String uid = defaultValue(tool.getUid(), tool.getName()).replaceAll("[^0-9A-Za-z]", "");
|
||||
uid = uid.length() > 7 ? uid.substring(0, 7) : uid;
|
||||
return "AXHUB-FS-" + category + "-" + uid.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String interfaceId(ToolMetadata tool) {
|
||||
int hash = Objects.hash(tool.getUid(), tool.getName());
|
||||
return "AXHUB" + String.format(Locale.ROOT, "%05d", Math.floorMod(hash, 100_000));
|
||||
}
|
||||
|
||||
private String formatTimeout(Long timeout) {
|
||||
return timeout == null ? "-" : String.format(Locale.ROOT, "%,d ms", timeout);
|
||||
}
|
||||
|
||||
private String yesNo(Boolean value) {
|
||||
return Boolean.TRUE.equals(value) ? "Y" : "N";
|
||||
}
|
||||
|
||||
private Sheet requiredSheet(Workbook workbook, String name) {
|
||||
Sheet sheet = workbook.getSheet(name);
|
||||
if (sheet == null) {
|
||||
throw new IllegalStateException("Excel 템플릿에 '" + name + "' 시트가 없습니다.");
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
private void resetSheet(Sheet sheet, int rows, int columns) {
|
||||
for (int index = sheet.getNumMergedRegions() - 1; index >= 0; index--) {
|
||||
sheet.removeMergedRegion(index);
|
||||
}
|
||||
for (int rowIndex = 0; rowIndex < rows; rowIndex++) {
|
||||
Row row = row(sheet, rowIndex);
|
||||
for (int column = 0; column < columns; column++) {
|
||||
cell(row, column).setBlank();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeSet(Sheet sheet, String range, String value, CellStyle style) {
|
||||
CellRangeAddress address = CellRangeAddress.valueOf(range);
|
||||
sheet.addMergedRegion(address);
|
||||
for (int rowIndex = address.getFirstRow(); rowIndex <= address.getLastRow(); rowIndex++) {
|
||||
for (int column = address.getFirstColumn(); column <= address.getLastColumn(); column++) {
|
||||
Cell target = cell(row(sheet, rowIndex), column);
|
||||
target.setCellStyle(style);
|
||||
if (rowIndex == address.getFirstRow() && column == address.getFirstColumn()) {
|
||||
target.setCellValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void set(Sheet sheet, String reference, String value) {
|
||||
CellReference cellReference = new CellReference(reference);
|
||||
Cell target = cell(row(sheet, cellReference.getRow()), cellReference.getCol());
|
||||
target.setBlank();
|
||||
target.setCellValue(defaultValue(value, ""));
|
||||
}
|
||||
|
||||
private void setStyled(Sheet sheet, int rowIndex, int column, String value, CellStyle style) {
|
||||
Cell target = cell(row(sheet, rowIndex), column);
|
||||
target.setBlank();
|
||||
target.setCellStyle(style);
|
||||
target.setCellValue(defaultValue(value, ""));
|
||||
}
|
||||
|
||||
private CellStyle style(Sheet sheet, String reference) {
|
||||
CellReference cellReference = new CellReference(reference);
|
||||
return cell(row(sheet, cellReference.getRow()), cellReference.getCol()).getCellStyle();
|
||||
}
|
||||
|
||||
private Row row(Sheet sheet, int rowIndex) {
|
||||
Row row = sheet.getRow(rowIndex);
|
||||
return row == null ? sheet.createRow(rowIndex) : row;
|
||||
}
|
||||
|
||||
private Cell cell(Row row, int column) {
|
||||
Cell cell = row.getCell(column);
|
||||
return cell == null ? row.createCell(column) : cell;
|
||||
}
|
||||
|
||||
private String value(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String defaultValue(String value, String fallback) {
|
||||
return isBlank(value) ? (fallback == null ? "" : fallback) : value;
|
||||
}
|
||||
|
||||
private boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private record SchemaField(String path, String type, String description, String length,
|
||||
boolean required, String example, boolean coded) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
public record GeneratedDocument(String fileName, byte[] content) {
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,383 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/geist-mono@5.0.1/400.css">
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style id="document-generator-styles">
|
||||
.document-generator {
|
||||
--docgen-panel: #111113;
|
||||
--docgen-panel-soft: #18181b;
|
||||
--docgen-line: #2a2a2f;
|
||||
--docgen-line-strong: #3f3f46;
|
||||
--docgen-text: #f4f4f5;
|
||||
--docgen-muted: #92929d;
|
||||
--docgen-blue: #3b82f6;
|
||||
--docgen-blue-soft: rgba(59, 130, 246, 0.12);
|
||||
--docgen-green: #6ee7a0;
|
||||
--docgen-amber: #facc50;
|
||||
--docgen-danger: #fb7185;
|
||||
color: var(--docgen-text);
|
||||
}
|
||||
|
||||
.document-generator *,
|
||||
.document-generator *::before,
|
||||
.document-generator *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.document-generator .docgen-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(340px, 0.9fr) minmax(460px, 1.35fr);
|
||||
margin: -2rem -1.5rem;
|
||||
min-height: 680px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-config,
|
||||
.document-generator .docgen-preview {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-config {
|
||||
border-right: 1px solid var(--docgen-line);
|
||||
}
|
||||
|
||||
.document-generator .docgen-section-title {
|
||||
margin: 0 0 6px;
|
||||
color: var(--docgen-text);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.document-generator .docgen-section-copy {
|
||||
margin: 0 0 24px;
|
||||
color: var(--docgen-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.document-generator .docgen-field {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-field-label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
color: var(--docgen-text);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.document-generator select,
|
||||
.document-generator input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid var(--docgen-line-strong);
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
background: var(--docgen-panel-soft);
|
||||
color: var(--docgen-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.document-generator select:focus,
|
||||
.document-generator input[type="text"]:focus {
|
||||
border-color: var(--docgen-blue);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.document-generator .docgen-hint {
|
||||
margin-top: 9px;
|
||||
color: var(--docgen-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 150px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-check-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 58px;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--docgen-line-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--docgen-panel-soft);
|
||||
color: #d4d4d8;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-check-card:hover {
|
||||
border-color: #52525b;
|
||||
}
|
||||
|
||||
.document-generator .docgen-check-card:has(input:checked) {
|
||||
border-color: rgba(59, 130, 246, 0.72);
|
||||
background: var(--docgen-blue-soft);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.document-generator input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin: 0;
|
||||
accent-color: var(--docgen-blue);
|
||||
}
|
||||
|
||||
.document-generator .docgen-mini-badge {
|
||||
margin-left: auto;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid var(--docgen-line-strong);
|
||||
border-radius: 5px;
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
font: 600 10px 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.document-generator .docgen-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-top: 30px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--docgen-line);
|
||||
}
|
||||
|
||||
.document-generator .docgen-source-label {
|
||||
color: var(--docgen-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-primary {
|
||||
padding: 13px 22px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: var(--docgen-blue);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-generator .docgen-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.document-generator .docgen-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.document-generator .docgen-meta-card {
|
||||
min-height: 155px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--docgen-line-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--docgen-panel-soft);
|
||||
}
|
||||
|
||||
.document-generator .docgen-meta-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-meta-title h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--docgen-text);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-meta-title p {
|
||||
margin: 0;
|
||||
color: var(--docgen-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.document-generator .docgen-status {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(110, 231, 160, 0.28);
|
||||
border-radius: 5px;
|
||||
background: rgba(110, 231, 160, 0.07);
|
||||
color: var(--docgen-green);
|
||||
font: 700 10px 'Geist Mono', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-generator .docgen-meta-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-tag {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 5px;
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
font: 600 10px 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.document-generator .docgen-tag.docgen-blue {
|
||||
border-color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.document-generator .docgen-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-row {
|
||||
display: grid;
|
||||
grid-template-columns: 42px 1fr auto;
|
||||
align-items: center;
|
||||
min-height: 68px;
|
||||
gap: 14px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid var(--docgen-line);
|
||||
border-radius: 7px;
|
||||
background: #151518;
|
||||
opacity: 0.42;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.document-generator .docgen-row.docgen-enabled {
|
||||
border-color: #303037;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.document-generator .docgen-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 6px;
|
||||
background: #27272a;
|
||||
color: #a1a1aa;
|
||||
font: 600 11px 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.document-generator .docgen-name {
|
||||
margin-bottom: 5px;
|
||||
color: var(--docgen-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.document-generator .docgen-description {
|
||||
color: var(--docgen-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-mode {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 5px;
|
||||
background: #27272a;
|
||||
color: var(--docgen-muted);
|
||||
font: 700 10px 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.document-generator .docgen-mode.docgen-auto { color: var(--docgen-green); }
|
||||
.document-generator .docgen-mode.docgen-mixed { color: var(--docgen-amber); }
|
||||
.document-generator .docgen-mode.docgen-separate {
|
||||
border-color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.document-generator .docgen-legend {
|
||||
margin: 16px 0 10px;
|
||||
color: var(--docgen-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.document-generator .docgen-files {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-file {
|
||||
min-height: 72px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed #3f3f46;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.document-generator .docgen-file.docgen-disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-generator .docgen-file-kind {
|
||||
margin-bottom: 9px;
|
||||
color: var(--docgen-green);
|
||||
font: 700 10px 'Geist Mono', monospace;
|
||||
}
|
||||
|
||||
.document-generator .docgen-file-name {
|
||||
color: #a1a1aa;
|
||||
font: 500 11px 'Geist Mono', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.document-generator .docgen-loading { color: var(--docgen-muted); }
|
||||
.document-generator .docgen-error { color: var(--docgen-danger); }
|
||||
|
||||
.docgen-toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1080;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #3f3f46;
|
||||
border-radius: 8px;
|
||||
background: #18181b;
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
color: #e4e4e7;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px);
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.docgen-toast.docgen-show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.document-generator .docgen-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.document-generator .docgen-config {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--docgen-line);
|
||||
}
|
||||
|
||||
.document-generator .docgen-two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #09090b;
|
||||
@@ -405,6 +782,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="document-tab" data-bs-toggle="tab" data-bs-target="#document" type="button" role="tab" onclick="loadDocumentGenerator()">Document Generator</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="list-tab" data-bs-toggle="tab" data-bs-target="#list" type="button" role="tab" onclick="loadToolList()">Registry</button>
|
||||
</li>
|
||||
@@ -595,6 +975,78 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Document Generator -->
|
||||
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
|
||||
<div class="docgen-workspace">
|
||||
<section class="docgen-config">
|
||||
<h2 class="docgen-section-title">Document Generator</h2>
|
||||
<p class="docgen-section-copy">Tool Metadata를 기준으로 개발 산출물 Excel을 생성합니다. 선택된 문서만 파일과 시트에 포함됩니다.</p>
|
||||
|
||||
<div class="docgen-field">
|
||||
<label class="docgen-field-label" for="docgenToolSelect">Tool</label>
|
||||
<select id="docgenToolSelect" disabled><option>Tool Metadata를 불러오는 중...</option></select>
|
||||
<div id="docgenToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
|
||||
</div>
|
||||
|
||||
<div class="docgen-two-col docgen-field">
|
||||
<div>
|
||||
<label class="docgen-field-label" for="docgenDocumentType">Document Type</label>
|
||||
<select id="docgenDocumentType"><option>Program Definition / Design</option></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="docgen-field-label" for="docgenVersion">Version</label>
|
||||
<input id="docgenVersion" type="text" value="1.0" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docgen-field">
|
||||
<label class="docgen-field-label">Included Sheets</label>
|
||||
<div class="docgen-check-grid">
|
||||
<label class="docgen-check-card"><input id="docgenProgramCheck" type="checkbox" checked><span>프로그램정의서</span></label>
|
||||
<label class="docgen-check-card"><input id="docgenProcessCheck" type="checkbox" checked><span>처리설계</span></label>
|
||||
<label class="docgen-check-card"><input id="docgenInterfaceCheck" type="checkbox" checked><span>인터페이스</span><span class="docgen-mini-badge">별도 파일</span></label>
|
||||
<label class="docgen-check-card"><input id="docgenRevisionCheck" type="checkbox" checked><span>개정이력</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docgen-action-row">
|
||||
<span class="docgen-source-label">Tool metadata · Generated XLSX</span>
|
||||
<button id="docgenGenerateBtn" class="docgen-primary" type="button" disabled>Generate EXCEL</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="docgen-preview">
|
||||
<div class="docgen-meta-card">
|
||||
<div class="docgen-meta-title">
|
||||
<div>
|
||||
<h2 id="docgenToolTitle" class="docgen-loading">Tool Metadata loading...</h2>
|
||||
<p id="docgenToolDescription">등록된 Tool 목록을 가져오는 중입니다.</p>
|
||||
</div>
|
||||
<span id="docgenMetadataStatus" class="docgen-status">SYNC</span>
|
||||
</div>
|
||||
<div class="docgen-meta-badges">
|
||||
<span id="docgenCategoryTag" class="docgen-tag">Category · -</span>
|
||||
<span id="docgenInterfaceTag" class="docgen-tag">Interface · -</span>
|
||||
<span class="docgen-tag docgen-blue">Tool Metadata</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docgen-list">
|
||||
<div id="docgenProgramRow" class="docgen-row docgen-enabled"><span class="docgen-number">01</span><div><div class="docgen-name">프로그램정의서</div><div class="docgen-description">Basic information · Runtime · Process summary</div></div><span class="docgen-mode docgen-auto">AUTO</span></div>
|
||||
<div id="docgenProcessRow" class="docgen-row docgen-enabled"><span class="docgen-number">02</span><div><div class="docgen-name">처리설계</div><div class="docgen-description">Tool metadata + common design</div></div><span class="docgen-mode docgen-mixed">MIXED</span></div>
|
||||
<div id="docgenInterfaceRow" class="docgen-row docgen-enabled"><span class="docgen-number">03</span><div><div class="docgen-name">인터페이스</div><div class="docgen-description">Request In · Response Out · 2 sheets</div></div><span class="docgen-mode docgen-separate">2 SHEETS · SEPARATE</span></div>
|
||||
<div id="docgenRevisionRow" class="docgen-row docgen-enabled"><span class="docgen-number">04</span><div><div class="docgen-name">개정이력</div><div class="docgen-description">Version · Date · Change information</div></div><span class="docgen-mode">MANUAL</span></div>
|
||||
</div>
|
||||
|
||||
<p class="docgen-legend">AUTO: Tool metadata · MIXED: Metadata + common design · MANUAL: Review required<br>Output Files · 인터페이스 정의서는 별도 Excel 파일로 생성됩니다.</p>
|
||||
<div class="docgen-files">
|
||||
<div id="docgenProgramFile" class="docgen-file"><div class="docgen-file-kind">PROGRAM SPEC</div><div id="docgenProgramFileName" class="docgen-file-name">tool_프로그램정의서_v1.0_YYYYMMDD.xlsx</div></div>
|
||||
<div id="docgenInterfaceFile" class="docgen-file"><div class="docgen-file-kind">INTERFACE SPEC · REQUEST IN / RESPONSE OUT</div><div id="docgenInterfaceFileName" class="docgen-file-name">tool_인터페이스정의서_v1.0_YYYYMMDD.xlsx</div></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tool List Form -->
|
||||
<div class="tab-pane fade" id="list" role="tabpanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
@@ -736,6 +1188,192 @@
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script id="document-generator-script">
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const state = { tools: [], selected: null };
|
||||
let checks = null;
|
||||
let initialized = false;
|
||||
|
||||
const dg = (id) => document.getElementById(id);
|
||||
|
||||
function initialize() {
|
||||
if (!initialized) {
|
||||
checks = {
|
||||
program: dg('docgenProgramCheck'),
|
||||
process: dg('docgenProcessCheck'),
|
||||
interface: dg('docgenInterfaceCheck'),
|
||||
revision: dg('docgenRevisionCheck')
|
||||
};
|
||||
Object.entries(checks).forEach(([name, checkbox]) => checkbox.addEventListener('change', () => {
|
||||
dg(`docgen${capitalize(name)}Row`).classList.toggle('docgen-enabled', checkbox.checked);
|
||||
updateOutputPreview();
|
||||
}));
|
||||
dg('docgenToolSelect').addEventListener('change', selectTool);
|
||||
dg('docgenVersion').addEventListener('input', updateOutputPreview);
|
||||
dg('docgenGenerateBtn').addEventListener('click', generateFiles);
|
||||
initialized = true;
|
||||
}
|
||||
loadTools();
|
||||
}
|
||||
|
||||
async function loadTools() {
|
||||
const select = dg('docgenToolSelect');
|
||||
const button = dg('docgenGenerateBtn');
|
||||
select.disabled = true;
|
||||
button.disabled = true;
|
||||
dg('docgenToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
|
||||
try {
|
||||
const response = await fetch('/mcp/api/v1/tools/list', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const tools = payload?.result?.tools;
|
||||
if (!Array.isArray(tools)) throw new Error('ToolMetadata 응답 형식이 올바르지 않습니다.');
|
||||
state.tools = tools.slice().sort((a, b) =>
|
||||
(a.categoryKey || '').localeCompare(b.categoryKey || '') ||
|
||||
(a.name || '').localeCompare(b.name || '')
|
||||
);
|
||||
select.innerHTML = state.tools.map((tool, index) =>
|
||||
`<option value="${index}">[${escapeHtml(tool.categoryKey || 'etc')}] ${escapeHtml(tool.name || tool.displayName || tool.uid)}</option>`
|
||||
).join('');
|
||||
select.disabled = state.tools.length === 0;
|
||||
dg('docgenToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
|
||||
if (state.tools.length) {
|
||||
select.value = '0';
|
||||
selectTool();
|
||||
} else {
|
||||
showMetadataError('등록된 Tool이 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
state.tools = [];
|
||||
state.selected = null;
|
||||
showMetadataError(error.message);
|
||||
select.innerHTML = '<option>Tool Metadata 조회 실패</option>';
|
||||
dg('docgenToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
function selectTool() {
|
||||
state.selected = state.tools[Number(dg('docgenToolSelect').value)] || null;
|
||||
const tool = state.selected;
|
||||
if (!tool) return;
|
||||
dg('docgenVersion').value = normalizeVersion(tool.semver || dg('docgenVersion').value || '1.0');
|
||||
dg('docgenToolTitle').classList.remove('docgen-loading', 'docgen-error');
|
||||
dg('docgenToolTitle').textContent = tool.displayName || tool.name;
|
||||
dg('docgenToolDescription').textContent = tool.description || '설명이 등록되지 않은 Tool입니다.';
|
||||
dg('docgenCategoryTag').textContent = `Category · ${tool.categoryKey || '-'}`;
|
||||
dg('docgenInterfaceTag').textContent = `Interface · ${tool.integrationType || '-'}`;
|
||||
dg('docgenMetadataStatus').textContent = tool.enabled === false ? 'DISABLED' : 'READY';
|
||||
updateOutputPreview();
|
||||
}
|
||||
|
||||
function showMetadataError(message) {
|
||||
dg('docgenToolTitle').className = 'docgen-error';
|
||||
dg('docgenToolTitle').textContent = 'Tool Metadata unavailable';
|
||||
dg('docgenToolDescription').textContent = message;
|
||||
dg('docgenMetadataStatus').textContent = 'ERROR';
|
||||
dg('docgenGenerateBtn').disabled = true;
|
||||
}
|
||||
|
||||
function updateOutputPreview() {
|
||||
const toolName = safeFilename(state.selected?.displayName || state.selected?.name || 'tool');
|
||||
const version = safeVersion(dg('docgenVersion').value);
|
||||
const date = yyyymmdd();
|
||||
const hasProgram = checks.program.checked || checks.process.checked || checks.revision.checked;
|
||||
dg('docgenProgramFile').classList.toggle('docgen-disabled', !hasProgram);
|
||||
dg('docgenInterfaceFile').classList.toggle('docgen-disabled', !checks.interface.checked);
|
||||
dg('docgenProgramFileName').textContent = `${toolName}_프로그램정의서_v${version}_${date}.xlsx`;
|
||||
dg('docgenInterfaceFileName').textContent = `${toolName}_인터페이스정의서_v${version}_${date}.xlsx`;
|
||||
dg('docgenGenerateBtn').disabled = !state.selected || (!hasProgram && !checks.interface.checked);
|
||||
}
|
||||
|
||||
async function generateFiles() {
|
||||
const tool = state.selected;
|
||||
if (!tool) return notify('생성할 Tool을 선택하세요.');
|
||||
const button = dg('docgenGenerateBtn');
|
||||
const version = safeVersion(dg('docgenVersion').value);
|
||||
const date = yyyymmdd();
|
||||
const toolName = safeFilename(tool.displayName || tool.name || 'tool');
|
||||
const hasProgram = checks.program.checked || checks.process.checked || checks.revision.checked;
|
||||
const request = {
|
||||
tool,
|
||||
version,
|
||||
includeProgram: checks.program.checked,
|
||||
includeProcess: checks.process.checked,
|
||||
includeRevision: checks.revision.checked
|
||||
};
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Generating...';
|
||||
try {
|
||||
if (hasProgram) {
|
||||
await requestDownload('/mcp/api/v1/admin/documents/program', request,
|
||||
`${toolName}_프로그램정의서_v${version}_${date}.xlsx`);
|
||||
}
|
||||
if (checks.interface.checked) {
|
||||
await requestDownload('/mcp/api/v1/admin/documents/interface', request,
|
||||
`${toolName}_인터페이스정의서_v${version}_${date}.xlsx`);
|
||||
}
|
||||
notify('선택한 Excel 문서 다운로드를 완료했습니다.');
|
||||
} catch (error) {
|
||||
notify(error.message);
|
||||
} finally {
|
||||
button.textContent = 'Generate EXCEL';
|
||||
updateOutputPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestDownload(url, payload, filename) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `문서 생성에 실패했습니다. (HTTP ${response.status})`;
|
||||
try {
|
||||
const error = await response.json();
|
||||
if (error?.error) message = error.error;
|
||||
} catch (_) {
|
||||
// JSON 오류 응답이 아닌 경우 기본 메시지를 사용합니다.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
function safeFilename(value) { return String(value).replace(/[\\/:*?"<>|]/g, '_').trim() || 'tool'; }
|
||||
function safeVersion(value) { return String(value || '1.0').replace(/^v/i, '').replace(/[^0-9A-Za-z._-]/g, '_') || '1.0'; }
|
||||
function normalizeVersion(value) { return String(value).replace(/^v/i, '') || '1.0'; }
|
||||
function yyyymmdd() {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
function capitalize(value) { return value.charAt(0).toUpperCase() + value.slice(1); }
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function notify(message) {
|
||||
const toast = dg('docgenToast');
|
||||
toast.textContent = message;
|
||||
toast.classList.add('docgen-show');
|
||||
window.setTimeout(() => toast.classList.remove('docgen-show'), 2800);
|
||||
}
|
||||
|
||||
window.loadDocumentGenerator = initialize;
|
||||
})();
|
||||
</script>
|
||||
<div id="docgenToast" class="docgen-toast"></div>
|
||||
<script>
|
||||
function handleFormSubmit(formId, apiUrl) {
|
||||
document.getElementById(formId).addEventListener('submit', function(e) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentGeneratorControllerTest {
|
||||
|
||||
@Mock
|
||||
private DocumentGeneratorService service;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(new DocumentGeneratorController(service)).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsGeneratedProgramWorkbook() throws Exception {
|
||||
byte[] workbook = "xlsx-content".getBytes(StandardCharsets.UTF_8);
|
||||
when(service.generateProgram(any())).thenReturn(new GeneratedDocument(
|
||||
"고객정보 조회 Tool_프로그램정의서_v1.0_20260811.xlsx", workbook));
|
||||
DocumentGenerationRequest request = new DocumentGenerationRequest(
|
||||
ToolMetadata.builder().uid("tool-uid").name("oth.cmm.customer.detail").build(),
|
||||
"1.0", true, true, true);
|
||||
|
||||
mockMvc.perform(post("/mcp/api/v1/admin/documents/program")
|
||||
.contentType("application/json")
|
||||
.content(objectMapper.writeValueAsBytes(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store"))
|
||||
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
|
||||
org.hamcrest.Matchers.containsString("attachment")))
|
||||
.andExpect(content().bytes(workbook));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package io.shinhanlife.dap.mcg.document;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.shinhanlife.dap.lib.dto.OperationType;
|
||||
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class DocumentGeneratorServiceTest {
|
||||
|
||||
private DocumentGeneratorService service;
|
||||
private ToolMetadata tool;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-08-11T03:00:00Z"), ZoneId.of("Asia/Seoul"));
|
||||
service = new DocumentGeneratorService(new ObjectMapper(), clock);
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
properties.put("customerId", Map.of(
|
||||
"type", "string",
|
||||
"description", "고객 ID",
|
||||
"pattern", "\\d{8}",
|
||||
"examples", List.of("12345678")));
|
||||
properties.put("pageSize", Map.of(
|
||||
"type", "integer",
|
||||
"description", "페이지 크기",
|
||||
"default", 20));
|
||||
|
||||
tool = ToolMetadata.builder()
|
||||
.uid("ebf042d9-2203-3992-9237-634a58515223")
|
||||
.semver("1.0.0")
|
||||
.displayName("테스트 고객조회 Tool")
|
||||
.name("oth.cmm.customer.detail")
|
||||
.description("테스트 고객의 상세정보를 조회합니다.")
|
||||
.categoryKey("cmm")
|
||||
.endpoint("http://was-oth:8084/mcp/oth.cmm.customer.detail")
|
||||
.podUrl("http://was-oth:8084")
|
||||
.integrationType("REST")
|
||||
.mciServiceId("CUST_001")
|
||||
.operationType(OperationType.READ)
|
||||
.timeoutMillis(5000L)
|
||||
.visible(true)
|
||||
.parametersSchema(Map.of(
|
||||
"type", "object",
|
||||
"properties", properties,
|
||||
"required", List.of("customerId")))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsSelectedProgramSheetsFromTemplate() throws Exception {
|
||||
GeneratedDocument document = service.generateProgram(
|
||||
new DocumentGenerationRequest(tool, "1.0", true, true, true));
|
||||
|
||||
assertThat(document.fileName())
|
||||
.isEqualTo("테스트 고객조회 Tool_프로그램정의서_v1.0_20260811.xlsx");
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(3);
|
||||
assertThat(workbook.getSheetName(0)).isEqualTo("프로그램정의서");
|
||||
assertThat(workbook.getSheetName(1)).isEqualTo("처리설계");
|
||||
assertThat(workbook.getSheetName(2)).isEqualTo("개정이력");
|
||||
assertThat(workbook.getSheet("프로그램정의서").getRow(4).getCell(1).getStringCellValue())
|
||||
.isEqualTo("테스트 고객조회 Tool");
|
||||
assertThat(workbook.getSheet("처리설계").getRow(0).getCell(0).getStringCellValue())
|
||||
.isEqualTo("처리설계");
|
||||
assertThat(workbook.getSheet("개정이력").getRow(3).getCell(0).getStringCellValue())
|
||||
.isEqualTo("1.0");
|
||||
assertThat(workbook.getNumCellStyles()).isGreaterThan(8);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesUncheckedProgramSheets() throws Exception {
|
||||
GeneratedDocument document = service.generateProgram(
|
||||
new DocumentGenerationRequest(tool, "v2.0", false, false, true));
|
||||
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
|
||||
assertThat(workbook.getSheetName(0)).isEqualTo("개정이력");
|
||||
assertThat(workbook.getSheetAt(0).getRow(3).getCell(0).getStringCellValue()).isEqualTo("2.0");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsRequestAndResponseInterfaceSheetsFromTemplate() throws Exception {
|
||||
GeneratedDocument document = service.generateInterface(
|
||||
new DocumentGenerationRequest(tool, "1.0", false, false, false));
|
||||
|
||||
assertThat(document.fileName())
|
||||
.isEqualTo("테스트 고객조회 Tool_인터페이스정의서_v1.0_20260811.xlsx");
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(document.content()))) {
|
||||
assertThat(workbook.getNumberOfSheets()).isEqualTo(2);
|
||||
assertThat(workbook.getSheetName(0)).isEqualTo("Request In");
|
||||
assertThat(workbook.getSheetName(1)).isEqualTo("Response Out");
|
||||
assertThat(workbook.getSheet("Request In").getRow(1).getCell(2).getStringCellValue())
|
||||
.isEqualTo("테스트 고객조회 Tool");
|
||||
assertThat(workbook.getSheet("Request In").getRow(3).getCell(3).getStringCellValue())
|
||||
.isEqualTo("http://was-oth:8084/mcp/oth.cmm.customer.detail");
|
||||
assertThat(workbook.getSheet("Request In").getRow(8).getCell(5).getStringCellValue())
|
||||
.isEqualTo("customerId");
|
||||
assertThat(workbook.getSheet("Request In").getRow(8).getCell(7).getStringCellValue())
|
||||
.isEqualTo("8");
|
||||
assertThat(workbook.getSheet("Request In").getRow(32).getCell(2).getStringCellValue())
|
||||
.contains("customerId", "12345678");
|
||||
assertThat(workbook.getSheet("Response Out").getRow(8).getCell(5).getStringCellValue())
|
||||
.isEqualTo("resultData");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProgramRequestWithoutSelectedSheet() {
|
||||
assertThatThrownBy(() -> service.generateProgram(
|
||||
new DocumentGenerationRequest(tool, "1.0", false, false, false)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("한 개 이상의 시트");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user