Compare commits

..

No commits in common. "8224cd02f59d43809c86b675880e7e3ba5448a24" and "661d78a22521579545810474636c1f782d01ba0f" have entirely different histories.

12 changed files with 75 additions and 218 deletions

View File

@ -47,7 +47,7 @@ window.HANWHA_FORMS = [
{
"code": "REPORT_HINT",
"level": "INFO",
"text": "배치가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
"text": "batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
}
]
},
@ -60,7 +60,7 @@ window.HANWHA_FORMS = [
{
"code": "UPLOAD_HINT",
"level": "INFO",
"text": "오류 샘플로 오류 시나리오를 확인한 뒤 정상 샘플을 재업로드합니다."
"text": "invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."
}
]
}
@ -436,7 +436,7 @@ function renderUploads() {
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-invalid">오류 샘플</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 전망</button>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 Forecast</button>
</div>
</div>
<div class="panel">
@ -592,7 +592,7 @@ function shellContent(content) {
<aside class="sidebar">
<div class="brand">
<h1>Hanwha Nexacro Demo</h1>
<p>Nexacro DSL에서 생성한 미리보기 화면</p>
<p>Spec driven preview generated from Nexacro DSL</p>
</div>
<div class="nav-list">${renderNav()}</div>
<div class="panel" style="margin-top: 18px;">

View File

@ -47,7 +47,7 @@ window.HANWHA_FORMS = [
{
"code": "REPORT_HINT",
"level": "INFO",
"text": "배치가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
"text": "batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
}
]
},
@ -60,7 +60,7 @@ window.HANWHA_FORMS = [
{
"code": "UPLOAD_HINT",
"level": "INFO",
"text": "오류 샘플로 오류 시나리오를 확인한 뒤 정상 샘플을 재업로드합니다."
"text": "invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."
}
]
}

View File

@ -57,7 +57,7 @@ this.txLoadReports = function(obj, e)
<Grid id="grdJobLogs" taborder="1" left="36" top="420" width="1368" height="320" binddataset="dsJobLogs">
<Formats><Format id="default"><Columns><Column size="228"/><Column size="228"/><Column size="228"/><Column size="228"/><Column size="228"/><Column size="228"/></Columns><Rows><Row size="32" band="head"/><Row size="28"/></Rows><Band id="head"><Cell col="0" text="로그ID"/><Cell col="1" text="작업유형"/><Cell col="2" text="참조ID"/><Cell col="3" text="레벨"/><Cell col="4" text="메시지"/><Cell col="5" text="생성시각"/></Band><Band id="body"><Cell col="0" text="bind:id"/><Cell col="1" text="bind:jobType"/><Cell col="2" text="bind:referenceId"/><Cell col="3" text="bind:logLevel"/><Cell col="4" text="bind:logMessage"/><Cell col="5" text="bind:createdAt"/></Band></Format></Formats>
</Grid>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="배치가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."/>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."/>
<Layouts>
<Layout width="1440" height="900" screenid="Desktop_screen"/>
</Layouts>

View File

@ -67,7 +67,7 @@ this.actUpload = function(obj, e)
<Grid id="grdIssues" taborder="1" left="36" top="486" width="1368" height="300" binddataset="dsIssues">
<Formats><Format id="default"><Columns><Column size="342"/><Column size="342"/><Column size="342"/><Column size="342"/></Columns><Rows><Row size="32" band="head"/><Row size="28"/></Rows><Band id="head"><Cell col="0" text="행번호"/><Cell col="1" text="오류코드"/><Cell col="2" text="오류메시지"/><Cell col="3" text="등급"/></Band><Band id="body"><Cell col="0" text="bind:rowNumber"/><Cell col="1" text="bind:issueCode"/><Cell col="2" text="bind:issueMessage"/><Cell col="3" text="bind:severityCode"/></Band></Format></Formats>
</Grid>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="오류 샘플로 오류 시나리오를 확인한 뒤 정상 샘플을 재업로드합니다."/>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."/>
<Layouts>
<Layout width="1440" height="900" screenid="Desktop_screen"/>
</Layouts>

View File

@ -10,11 +10,8 @@ RUN gradle --no-daemon bootJar
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-nanum \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /workspace/build/libs/*.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

View File

@ -101,7 +101,7 @@ public class ConsolidationService {
);
}
reportService.generateReports(runId, fiscalPeriod, payload);
String summary = "기여내역=" + payload.getContributionRows().size() + "건, 제거내역=" + payload.getEliminationRows().size() + "건, 전망=" + payload.getForecastRows().size() + "";
String summary = "rows=" + payload.getContributionRows().size() + ", eliminations=" + payload.getEliminationRows().size() + ", forecast=" + payload.getForecastRows().size();
consolidationMapper.markRunSuccess(runId, summary);
consolidationMapper.insertJobLog("CONSOLIDATION", runId, "SUCCESS", "집계와 리포트 생성을 완료했습니다.");
} catch (Exception exception) {
@ -155,7 +155,7 @@ public class ConsolidationService {
eliminationRow.put("partnerEntityCode", partnerEntityCode);
eliminationRow.put("accountCode", accountCode);
eliminationRow.put("eliminationAmount", weightedAmount.negate().setScale(2, RoundingMode.HALF_UP));
eliminationRow.put("note", "내부거래 제거");
eliminationRow.put("note", "Intercompany elimination");
payload.getEliminationRows().add(eliminationRow);
}
}

View File

@ -8,14 +8,13 @@ import com.hanwha.nexacrodemo.consolidation.ConsolidationPayload;
import com.hanwha.nexacrodemo.minio.ObjectStorageService;
import com.hanwha.nexacrodemo.minio.StoredObject;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.FontFactory;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.BaseFont;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
@ -23,13 +22,10 @@ import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -47,7 +43,6 @@ import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
@ -60,19 +55,15 @@ public class ReportService {
private final ReportMapper reportMapper;
private final ConsolidationMapper consolidationMapper;
private final ObjectStorageService objectStorageService;
private final String pdfFontPath;
private volatile BaseFont pdfBaseFont;
public ReportService(
ReportMapper reportMapper,
ConsolidationMapper consolidationMapper,
ObjectStorageService objectStorageService,
@Value("${app.report.pdf-font-path:}") String pdfFontPath
ObjectStorageService objectStorageService
) {
this.reportMapper = reportMapper;
this.consolidationMapper = consolidationMapper;
this.objectStorageService = objectStorageService;
this.pdfFontPath = pdfFontPath;
}
public TxResponse loadRunOverview() {
@ -131,9 +122,9 @@ public class ReportService {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Map<String, CellStyle> styles = createExcelStyles(workbook);
buildSummarySheet(workbook, fiscalPeriod, payload, styles);
buildDetailSheet(workbook, "기여내역", fiscalPeriod, payload.getContributionRows(), styles);
buildDetailSheet(workbook, "제거내역", fiscalPeriod, payload.getEliminationRows(), styles);
buildDetailSheet(workbook, "전망", fiscalPeriod, payload.getForecastRows(), styles);
buildDetailSheet(workbook, "Contributions", fiscalPeriod, payload.getContributionRows(), styles);
buildDetailSheet(workbook, "Eliminations", fiscalPeriod, payload.getEliminationRows(), styles);
buildDetailSheet(workbook, "Forecast", fiscalPeriod, payload.getForecastRows(), styles);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
@ -146,13 +137,13 @@ public class ReportService {
Document document = new Document(PageSize.A4.rotate(), 36, 36, 36, 36);
PdfWriter.getInstance(document, outputStream);
document.open();
addPdfTitle(document, "한화 연결 집계 보고서", 18, new Color(24, 54, 93));
addPdfTitle(document, "회계기간 " + fiscalPeriod + " | 생성시각 " + generatedAt(), 10, new Color(90, 96, 106));
addPdfTitle(document, "Hanwha Consolidation Summary", 18, new Color(24, 54, 93));
addPdfTitle(document, "Fiscal period " + fiscalPeriod + " | Generated " + generatedAt(), 10, new Color(90, 96, 106));
document.add(new Paragraph(" "));
addSummaryTable(document, payload.getMetrics());
addDetailTable(document, "기여내역", payload.getContributionRows(), 8);
addDetailTable(document, "제거내역", payload.getEliminationRows(), 8);
addDetailTable(document, "전망", payload.getForecastRows(), 8);
addDetailTable(document, "Contributions", payload.getContributionRows(), 8);
addDetailTable(document, "Eliminations", payload.getEliminationRows(), 8);
addDetailTable(document, "Forecast", payload.getForecastRows(), 8);
document.close();
return outputStream.toByteArray();
}
@ -163,24 +154,24 @@ public class ReportService {
ConsolidationPayload payload,
Map<String, CellStyle> styles
) {
Sheet sheet = workbook.createSheet("요약");
Sheet sheet = workbook.createSheet("Summary");
sheet.setDisplayGridlines(false);
Row titleRow = sheet.createRow(0);
titleRow.setHeightInPoints(28);
createCell(titleRow, 0, "한화 연결 집계 요약", styles.get("title"));
createCell(titleRow, 0, "Hanwha Consolidation Summary", styles.get("title"));
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 3));
Row subtitleRow = sheet.createRow(1);
createCell(subtitleRow, 0, "회계기간 " + fiscalPeriod + " | 생성시각 " + generatedAt(), styles.get("subtitle"));
createCell(subtitleRow, 0, "Fiscal period " + fiscalPeriod + " | Generated " + generatedAt(), styles.get("subtitle"));
sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 3));
Row sectionRow = sheet.createRow(3);
createCell(sectionRow, 0, "주요 지표", styles.get("section"));
createCell(sectionRow, 0, "Performance Snapshot", styles.get("section"));
Row headerRow = sheet.createRow(4);
createCell(headerRow, 0, "항목", styles.get("header"));
createCell(headerRow, 1, "", styles.get("header"));
createCell(headerRow, 0, "Metric", styles.get("header"));
createCell(headerRow, 1, "Value", styles.get("header"));
int rowIndex = 5;
for (Map.Entry<String, BigDecimal> metric : payload.getMetrics().entrySet()) {
@ -209,16 +200,16 @@ public class ReportService {
Row titleRow = sheet.createRow(0);
titleRow.setHeightInPoints(24);
createCell(titleRow, 0, sheetName, styles.get("title"));
mergeIfNeeded(sheet, 0, columnCount);
createCell(titleRow, 0, sheetName + " Detail", styles.get("title"));
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, Math.max(0, columnCount - 1)));
Row subtitleRow = sheet.createRow(1);
createCell(subtitleRow, 0, "회계기간 " + fiscalPeriod + " | 건수 " + rows.size(), styles.get("subtitle"));
mergeIfNeeded(sheet, 1, columnCount);
createCell(subtitleRow, 0, "Fiscal period " + fiscalPeriod + " | Rows " + rows.size(), styles.get("subtitle"));
sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, Math.max(0, columnCount - 1)));
if (rows.isEmpty()) {
Row emptyRow = sheet.createRow(3);
createCell(emptyRow, 0, "데이터가 없습니다.", styles.get("empty"));
createCell(emptyRow, 0, "No data available", styles.get("empty"));
sheet.setColumnWidth(0, 24 * 256);
return;
}
@ -356,18 +347,12 @@ public class ReportService {
cell.setCellStyle(style);
}
private void mergeIfNeeded(Sheet sheet, int rowIndex, int columnCount) {
if (columnCount > 1) {
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, columnCount - 1));
}
}
private CellStyle styleForKey(Map<String, CellStyle> styles, String key) {
return key.toLowerCase().contains("ratio") ? styles.get("ratio") : styles.get("number");
}
private void addPdfTitle(Document document, String text, int size, Color color) {
Font font = pdfFont(size, Font.BOLD, color);
Font font = FontFactory.getFont(FontFactory.HELVETICA_BOLD, size, color);
Paragraph paragraph = new Paragraph(text, font);
paragraph.setAlignment(Element.ALIGN_LEFT);
paragraph.setSpacingAfter(4f);
@ -379,8 +364,8 @@ public class ReportService {
table.setWidthPercentage(100f);
table.setSpacingBefore(6f);
table.setSpacingAfter(12f);
table.addCell(pdfHeaderCell("항목"));
table.addCell(pdfHeaderCell(""));
table.addCell(pdfHeaderCell("Metric"));
table.addCell(pdfHeaderCell("Value"));
for (Map.Entry<String, BigDecimal> metric : metrics.entrySet()) {
table.addCell(pdfBodyCell(label(metric.getKey()), Element.ALIGN_LEFT));
table.addCell(pdfBodyCell(format(metric.getValue(), metric.getKey()), Element.ALIGN_RIGHT));
@ -389,14 +374,14 @@ public class ReportService {
}
private void addDetailTable(Document document, String title, List<Map<String, Object>> rows, int maxRows) {
Font sectionFont = pdfFont(12, Font.BOLD, new Color(24, 54, 93));
Font sectionFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, new Color(24, 54, 93));
Paragraph section = new Paragraph(title, sectionFont);
section.setSpacingBefore(8f);
section.setSpacingAfter(6f);
document.add(section);
if (rows.isEmpty()) {
document.add(new Paragraph("데이터가 없습니다.", pdfFont(10, Font.ITALIC, new Color(120, 120, 120))));
document.add(new Paragraph("No data available", FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, new Color(120, 120, 120))));
return;
}
@ -420,14 +405,14 @@ public class ReportService {
if (rows.size() > maxRows) {
document.add(new Paragraph(
"전체 " + rows.size() + "건 중 상위 " + maxRows + "건만 표시합니다.",
pdfFont(9, Font.ITALIC, new Color(120, 120, 120))
"Showing first " + maxRows + " of " + rows.size() + " rows.",
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 9, new Color(120, 120, 120))
));
}
}
private PdfPCell pdfHeaderCell(String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, pdfFont(9, Font.BOLD, Color.WHITE)));
PdfPCell cell = new PdfPCell(new Phrase(text, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE)));
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
cell.setBackgroundColor(new Color(243, 127, 32));
@ -437,7 +422,7 @@ public class ReportService {
}
private PdfPCell pdfBodyCell(String text, int alignment) {
PdfPCell cell = new PdfPCell(new Phrase(text, pdfFont(9, Font.NORMAL, Color.DARK_GRAY)));
PdfPCell cell = new PdfPCell(new Phrase(text, FontFactory.getFont(FontFactory.HELVETICA, 9, Color.DARK_GRAY)));
cell.setHorizontalAlignment(alignment);
cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
cell.setBorderColor(new Color(226, 229, 233));
@ -445,53 +430,6 @@ public class ReportService {
return cell;
}
private Font pdfFont(float size, int style, Color color) {
BaseFont baseFont = resolvePdfBaseFont();
if (baseFont == null) {
return new Font(Font.HELVETICA, size, style, color);
}
return new Font(baseFont, size, style, color);
}
private BaseFont resolvePdfBaseFont() {
if (pdfBaseFont != null) {
return pdfBaseFont;
}
synchronized (this) {
if (pdfBaseFont != null) {
return pdfBaseFont;
}
for (String candidate : pdfFontCandidates()) {
try {
pdfBaseFont = BaseFont.createFont(candidate, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
return pdfBaseFont;
} catch (DocumentException | IOException ignored) {
// Fall through to the next candidate. The Docker image installs NanumGothic,
// but local test environments may not have a Korean font available.
}
}
return null;
}
}
private List<String> pdfFontCandidates() {
List<String> candidates = new ArrayList<>();
if (pdfFontPath != null && !pdfFontPath.isBlank()) {
candidates.add(pdfFontPath.trim());
}
addIfExists(candidates, "/usr/share/fonts/truetype/nanum/NanumGothic.ttf");
addIfExists(candidates, "/usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf");
addIfExists(candidates, "/workspace/server/api/src/main/resources/fonts/NanumGothic.ttf");
addIfExists(candidates, "/app/resources/fonts/NanumGothic.ttf");
return candidates;
}
private void addIfExists(List<String> candidates, String path) {
if (Files.exists(Path.of(path))) {
candidates.add(path);
}
}
private boolean isNumericValue(Object value) {
return value instanceof Number || value instanceof BigDecimal;
}
@ -526,22 +464,22 @@ public class ReportService {
private static Map<String, String> buildLabels() {
Map<String, String> labels = new LinkedHashMap<>();
labels.put("acceptedTrialRows", "승인된 TB 건수");
labels.put("acceptedForecastRows", "승인된 전망 건수");
labels.put("grossContributionKrw", "총 기여금액 (KRW)");
labels.put("eliminationKrw", "제거금액 (KRW)");
labels.put("forecastKrw", "전망금액 (KRW)");
labels.put("netContributionKrw", "순 기여금액 (KRW)");
labels.put("entityCode", "법인코드");
labels.put("accountCode", "계정코드");
labels.put("partnerEntityCode", "상대법인코드");
labels.put("translatedAmount", "환산금액");
labels.put("ownershipRatio", "지분율");
labels.put("finalAmount", "최종반영금액");
labels.put("internalTrade", "내부거래 여부");
labels.put("eliminationAmount", "제거금액");
labels.put("note", "비고");
labels.put("scenarioCode", "시나리오");
labels.put("acceptedTrialRows", "Accepted Trial Rows");
labels.put("acceptedForecastRows", "Accepted Forecast Rows");
labels.put("grossContributionKrw", "Gross Contribution (KRW)");
labels.put("eliminationKrw", "Elimination (KRW)");
labels.put("forecastKrw", "Forecast (KRW)");
labels.put("netContributionKrw", "Net Contribution (KRW)");
labels.put("entityCode", "Entity Code");
labels.put("accountCode", "Account Code");
labels.put("partnerEntityCode", "Partner Entity");
labels.put("translatedAmount", "Translated Amount");
labels.put("ownershipRatio", "Ownership Ratio");
labels.put("finalAmount", "Final Amount");
labels.put("internalTrade", "Internal Trade");
labels.put("eliminationAmount", "Elimination Amount");
labels.put("note", "Note");
labels.put("scenarioCode", "Scenario");
return labels;
}
}

View File

@ -43,8 +43,6 @@ app:
enabled: false
poll-interval-ms: ${BATCH_POLL_INTERVAL_MS:5000}
worker-name: ${BATCH_WORKER_NAME:batch-1}
report:
pdf-font-path: ${PDF_FONT_PATH:}
storage:
provider: ${STORAGE_PROVIDER:minio}
bucket: ${MINIO_BUCKET:reports}
@ -59,3 +57,4 @@ logging:
level:
root: INFO
com.hanwha.nexacrodemo: INFO

View File

@ -10,7 +10,6 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hanwha.nexacrodemo.upload.TestWorkbookFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@ -21,14 +20,12 @@ import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ConsolidationIntegrationTest {
@Autowired
@ -42,27 +39,24 @@ class ConsolidationIntegrationTest {
@Test
void validUploadsCanBeConsolidatedAndReported() throws Exception {
String fiscalPeriod = "2026-04";
MockHttpSession session = login("operator", "demo1234");
upload(session, "trial-balance", fiscalPeriod, "tb-valid.xlsx", withFiscalPeriod(TestWorkbookFactory.trialBalanceValid(), fiscalPeriod));
upload(session, "forecast", fiscalPeriod, "forecast-valid.xlsx", withFiscalPeriod(TestWorkbookFactory.forecastValid(), fiscalPeriod));
upload(session, "trial-balance", "tb-valid.xlsx", TestWorkbookFactory.trialBalanceValid());
upload(session, "forecast", "forecast-valid.xlsx", TestWorkbookFactory.forecastValid());
MvcResult runResult = mockMvc.perform(post("/api/consolidations/runs")
.contentType(MediaType.APPLICATION_JSON)
.session(session)
.content("""
{
"fiscalPeriod": "%s",
"fiscalPeriod": "2026-03",
"reportCurrency": "KRW"
}
""".formatted(fiscalPeriod)))
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("REQUESTED"))
.andReturn();
long runId = objectMapper.readTree(runResult.getResponse().getContentAsByteArray()).path("id").asLong();
consolidationService.processPendingRuns("test-worker");
mockMvc.perform(get("/api/tx/consolidations/overview").session(session))
@ -71,24 +65,20 @@ class ConsolidationIntegrationTest {
MvcResult overviewResult = mockMvc.perform(get("/api/tx/reports/overview").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.datasets.artifacts.length()").value(2))
.andExpect(jsonPath("$.datasets.jobLogs[0].logLevel").exists())
.andReturn();
JsonNode artifacts = objectMapper.readTree(overviewResult.getResponse().getContentAsByteArray())
.path("datasets")
.path("artifacts");
int artifactCountForRun = 0;
long excelArtifactId = -1L;
for (JsonNode artifact : artifacts) {
if (artifact.path("runId").asLong() != runId) {
continue;
}
artifactCountForRun++;
if ("EXCEL".equals(artifact.path("artifactType").asText())) {
excelArtifactId = artifact.path("id").asLong();
break;
}
}
Assertions.assertEquals(2, artifactCountForRun);
Assertions.assertTrue(excelArtifactId > 0, "EXCEL artifact should exist");
MvcResult excelDownload = mockMvc.perform(get("/api/reports/{artifactId}/download", excelArtifactId).session(session))
@ -96,67 +86,13 @@ class ConsolidationIntegrationTest {
.andReturn();
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(excelDownload.getResponse().getContentAsByteArray()))) {
Assertions.assertEquals("한화 연결 집계 요약", workbook.getSheet("요약").getRow(0).getCell(0).getStringCellValue());
Assertions.assertEquals("법인코드", workbook.getSheet("기여내역").getRow(3).getCell(0).getStringCellValue());
Assertions.assertNotNull(workbook.getSheet("전망"));
Assertions.assertEquals("Hanwha Consolidation Summary", workbook.getSheet("Summary").getRow(0).getCell(0).getStringCellValue());
Assertions.assertEquals("Entity Code", workbook.getSheet("Contributions").getRow(3).getCell(0).getStringCellValue());
Assertions.assertNotNull(workbook.getSheet("Forecast"));
}
}
@Test
void consolidationStillSucceedsWhenForecastSectionIsEmpty() throws Exception {
String fiscalPeriod = "2026-03";
MockHttpSession session = login("operator", "demo1234");
upload(session, "trial-balance", fiscalPeriod, "tb-valid.xlsx", TestWorkbookFactory.trialBalanceValid());
MvcResult runResult = mockMvc.perform(post("/api/consolidations/runs")
.contentType(MediaType.APPLICATION_JSON)
.session(session)
.content("""
{
"fiscalPeriod": "%s",
"reportCurrency": "KRW"
}
""".formatted(fiscalPeriod)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("REQUESTED"))
.andReturn();
long runId = objectMapper.readTree(runResult.getResponse().getContentAsByteArray()).path("id").asLong();
consolidationService.processPendingRuns("test-worker");
MvcResult overviewResult = mockMvc.perform(get("/api/tx/reports/overview").session(session))
.andExpect(status().isOk())
.andReturn();
JsonNode artifacts = objectMapper.readTree(overviewResult.getResponse().getContentAsByteArray())
.path("datasets")
.path("artifacts");
int artifactCountForRun = 0;
long excelArtifactId = -1L;
for (JsonNode artifact : artifacts) {
if (artifact.path("runId").asLong() != runId) {
continue;
}
artifactCountForRun++;
if ("EXCEL".equals(artifact.path("artifactType").asText())) {
excelArtifactId = artifact.path("id").asLong();
}
}
Assertions.assertEquals(2, artifactCountForRun);
Assertions.assertTrue(excelArtifactId > 0, "EXCEL artifact should exist");
MvcResult excelDownload = mockMvc.perform(get("/api/reports/{artifactId}/download", excelArtifactId).session(session))
.andExpect(status().isOk())
.andReturn();
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(excelDownload.getResponse().getContentAsByteArray()))) {
Assertions.assertEquals("데이터가 없습니다.", workbook.getSheet("전망").getRow(3).getCell(0).getStringCellValue());
}
}
private void upload(MockHttpSession session, String templateCode, String fiscalPeriod, String fileName, byte[] content) throws Exception {
private void upload(MockHttpSession session, String templateCode, String fileName, byte[] content) throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
fileName,
@ -167,27 +103,12 @@ class ConsolidationIntegrationTest {
mockMvc.perform(multipart("/api/uploads")
.file(file)
.param("templateCode", templateCode)
.param("fiscalPeriod", fiscalPeriod)
.param("fiscalPeriod", "2026-03")
.session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("ACCEPTED"));
}
private byte[] withFiscalPeriod(byte[] content, String fiscalPeriod) throws Exception {
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) {
var dataSheet = workbook.getSheet("DATA");
for (int rowIndex = 1; rowIndex <= dataSheet.getLastRowNum(); rowIndex++) {
var row = dataSheet.getRow(rowIndex);
if (row != null && row.getCell(0) != null) {
row.getCell(0).setCellValue(fiscalPeriod);
}
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private MockHttpSession login(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)

View File

@ -59,4 +59,5 @@ transactions:
messages:
- code: REPORT_HINT
level: INFO
text: 배치가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다.
text: batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다.

View File

@ -103,4 +103,5 @@ actions:
messages:
- code: UPLOAD_HINT
level: INFO
text: 오류 샘플로 오류 시나리오를 확인한 뒤 정상 샘플을 재업로드합니다.
text: invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다.

View File

@ -368,7 +368,7 @@ function renderUploads() {
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-invalid">오류 샘플</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 전망</button>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 Forecast</button>
</div>
</div>
<div class="panel">
@ -524,7 +524,7 @@ function shellContent(content) {
<aside class="sidebar">
<div class="brand">
<h1>{{appTitle}}</h1>
<p>Nexacro DSL에서 생성한 미리보기 화면</p>
<p>Spec driven preview generated from Nexacro DSL</p>
</div>
<div class="nav-list">${renderNav()}</div>
<div class="panel" style="margin-top: 18px;">