Initial Hanwha Nexacro demo scaffold

This commit is contained in:
DongHeon Jang 2026-04-12 11:39:43 +09:00
commit d38d14ed8e
99 changed files with 6442 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
tools/nexacro-gen/node_modules/
server/api/.gradle/
server/api/build/
.DS_Store

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# Hanwha Nexacro Demo
업로드/검증 중심의 연결 재무 데모 프로젝트다. 로컬에서는 `Docker Compose`로 API, batch, PostgreSQL, MinIO, Nginx preview를 실행하고, UI 원본은 `spec/nexacro/*.yaml`로 관리한다.
## 구조
- `spec/nexacro`: AI가 직접 수정하는 Nexacro 화면 명세
- `tools/nexacro-gen`: 명세를 Nexacro source와 preview UI로 변환하는 생성기
- `client/nexacro-src`: 생성된 Nexacro 프로젝트 소스
- `client/nexacro-deploy`: preview UI 및 향후 Studio 배포 산출물 위치
- `server/api`: Spring Boot API, batch, report 생성
- `sample-data`: 데모용 업로드 파일
- `docs`: 운영/치환/Windows build 문서
- `ops`: Nginx 설정
## 빠른 시작
1. Nexacro source와 preview를 재생성한다.
```bash
cd tools/nexacro-gen
npm install
npm run generate
```
2. 데모용 Excel 파일을 생성한다.
```bash
python3 scripts/generate_sample_uploads.py
```
3. 전체 스택을 실행한다.
```bash
docker compose up --build
```
4. 브라우저에서 연다.
- Preview UI: `http://localhost:8080`
- MinIO console: `http://localhost:19001`
## 기본 계정
- `admin / demo1234`
- `operator / demo1234`
- `viewer / demo1234`
## 데모 시나리오
1. 로그인
2. 기준정보 확인
3. `sample-data/trial-balance-invalid.xlsx` 업로드 후 오류내역 확인
4. `sample-data/trial-balance-valid.xlsx` 업로드
5. `sample-data/forecast-valid.xlsx` 업로드
6. 집계 실행
7. 배치 완료 후 리포트 다운로드와 로그 확인
## Nexacro 운영 원칙
- 기준 소스는 `spec/nexacro/*.yaml`이다.
- `client/nexacro-src`는 생성 결과물로 취급한다.
- Windows의 Nexacro Studio는 생성된 소스를 열어 검수하고 웹 배포 산출물을 생성하는 용도로 사용한다.

View File

@ -0,0 +1,555 @@
window.HANWHA_FORMS = [
{
"formId": "frmConsolidation",
"title": "집계 실행",
"route": "consolidation",
"authority": "OPERATOR",
"messages": [
{
"code": "RUN_HINT",
"level": "INFO",
"text": "업로드가 ACCEPTED 상태인 파일만 집계 대상에 포함됩니다."
}
]
},
{
"formId": "frmLogin",
"title": "로그인",
"route": "login",
"authority": "PUBLIC",
"messages": [
{
"code": "LOGIN_HINT",
"level": "INFO",
"text": "기본 계정 admin/operator/viewer, 비밀번호 demo1234"
}
]
},
{
"formId": "frmMasterData",
"title": "기준정보 관리",
"route": "master",
"authority": "VIEWER",
"messages": [
{
"code": "MASTER_LOAD",
"level": "INFO",
"text": "기준정보, 계정, 환율, 지분율을 확인합니다."
}
]
},
{
"formId": "frmReportsOps",
"title": "리포트/운영",
"route": "reports",
"authority": "VIEWER",
"messages": [
{
"code": "REPORT_HINT",
"level": "INFO",
"text": "batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
}
]
},
{
"formId": "frmUploadValidation",
"title": "업로드/검증",
"route": "uploads",
"authority": "OPERATOR",
"messages": [
{
"code": "UPLOAD_HINT",
"level": "INFO",
"text": "invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."
}
]
}
];
const state = {
currentRoute: "login",
session: null,
master: null,
uploads: null,
runs: null,
reports: null,
selectedBatchId: null
};
const formMap = new Map(window.HANWHA_FORMS.map((form) => [form.route, form]));
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {
...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }),
...(options.headers || {})
},
...options
});
if (!response.ok) {
const payload = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(payload.message || "요청 처리에 실패했습니다.");
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return response.json();
}
return response.blob();
}
function formatValue(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
return value;
}
function table(columns, rows, options = {}) {
const body = rows.length
? rows
.map(
(row) => `<tr>${columns
.map((column) => {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return `<td>${formatValue(value)}</td>`;
})
.join("")}</tr>`
)
.join("")
: `<tr><td colspan="${columns.length}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`)
.join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function pill(value) {
return `<span class="pill ${value}">${value}</span>`;
}
function renderNav() {
return window.HANWHA_FORMS.map((form) => {
const active = state.currentRoute === form.route ? "active" : "";
const locked = form.authority !== "PUBLIC" && !state.session;
return `<button class="nav-item ${active}" data-route="${form.route}" ${locked ? "disabled" : ""}><strong>${form.title}</strong><div class="muted">${form.authority}</div></button>`;
}).join("");
}
function heroContent() {
const form = formMap.get(state.currentRoute);
const note = form?.messages?.[0]?.text || "Spec driven preview";
return `<div class="hero-card"><h2>${form.title}</h2><p>${note}</p></div>`;
}
function renderLogin() {
return `
${heroContent()}
<div class="panel login-box">
<h3>세션 로그인</h3>
<div class="notice">기본 계정: admin/operator/viewer / demo1234</div>
<div class="row"><label>사용자 ID</label><input id="login-username" value="admin" /></div>
<div class="row"><label>비밀번호</label><input id="login-password" type="password" value="demo1234" /></div>
<div class="row actions">
<button data-action="login">로그인</button>
</div>
</div>
`;
}
function renderMaster() {
const datasets = state.master?.datasets || {};
const entities = datasets.entities || [];
const accounts = datasets.accounts || [];
const fxRates = datasets.fxRates || [];
const ownerships = datasets.ownerships || [];
return `
${heroContent()}
<div class="grid-4">
<div class="status-card"><div class="label">법인 </div><div class="value">${entities.length}</div></div>
<div class="status-card"><div class="label">계정 </div><div class="value">${accounts.length}</div></div>
<div class="status-card"><div class="label">환율 </div><div class="value">${fxRates.length}</div></div>
<div class="status-card"><div class="label">지분율 </div><div class="value">${ownerships.length}</div></div>
</div>
<div class="grid-2" style="margin-top: 18px;">
<div class="panel">
<h3>법인정보</h3>
${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)}
</div>
<div class="panel">
<h3>계정코드</h3>
${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)}
</div>
<div class="panel">
<h3>환율</h3>
${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)}
</div>
<div class="panel">
<h3>지분율</h3>
${table([{ id: "parentEntityCode", text: "모법인" }, { id: "childEntityCode", text: "자법인" }, { id: "ownershipRatio", text: "지분율" }], ownerships)}
</div>
</div>
`;
}
function renderUploads() {
const datasets = state.uploads?.datasets || {};
const batches = datasets.uploadBatches || [];
const issues = datasets.validationIssues || [];
return `
${heroContent()}
<div class="panel">
<h3>업로드</h3>
<div class="row"><label>템플릿</label>
<select id="upload-template">
<option value="trial-balance">trial-balance</option>
<option value="forecast">forecast</option>
</select>
</div>
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></div>
<div class="row"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions">
<button data-action="upload">파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
<div class="panel">
<h3>업로드 이력</h3>
${table(
[
{ id: "id", text: "배치ID" },
{ id: "templateCode", text: "템플릿" },
{ id: "fiscalPeriod", text: "회계기간" },
{ id: "statusCode", text: "상태" },
{ id: "originalFilename", text: "파일명" },
{ id: "rowCount", text: "건수" },
{ id: "errorCount", text: "오류" },
{ id: "uploadedAt", text: "업로드시각" }
],
batches,
{
render(column, value) {
if (column.id === "statusCode") {
return pill(value);
}
return value;
}
}
)}
</div>
<div class="panel">
<h3>오류내역</h3>
${table(
[
{ id: "batchId", text: "배치ID" },
{ id: "rowNumber", text: "행" },
{ id: "issueCode", text: "오류코드" },
{ id: "issueMessage", text: "오류메시지" },
{ id: "severityCode", text: "등급" }
],
issues,
{
render(column, value) {
if (column.id === "severityCode") {
return pill(value);
}
return value;
}
}
)}
</div>
`;
}
function renderConsolidation() {
const runs = state.runs?.datasets?.runs || [];
return `
${heroContent()}
<div class="panel">
<h3>집계 실행</h3>
<div class="row"><label>회계기간</label><input id="run-period" value="2026-03" /></div>
<div class="row actions">
<button data-action="request-run">집계 실행</button>
<button class="secondary" data-action="reload-runs">상태 새로고침</button>
</div>
</div>
<div class="panel">
<h3>집계 이력</h3>
${table(
[
{ id: "id", text: "실행ID" },
{ id: "fiscalPeriod", text: "회계기간" },
{ id: "statusCode", text: "상태" },
{ id: "requestedBy", text: "요청자" },
{ id: "requestedAt", text: "요청시각" },
{ id: "finishedAt", text: "완료시각" },
{ id: "summaryMessage", text: "요약" }
],
runs,
{
render(column, value) {
if (column.id === "statusCode") {
return pill(value);
}
return value;
}
}
)}
</div>
`;
}
function renderReports() {
const datasets = state.reports?.datasets || {};
const artifacts = datasets.artifacts || [];
const logs = datasets.jobLogs || [];
return `
${heroContent()}
<div class="grid-3">
<div class="status-card"><div class="label">산출물 </div><div class="value">${artifacts.length}</div></div>
<div class="status-card"><div class="label">최근 로그 </div><div class="value">${logs.length}</div></div>
<div class="status-card"><div class="label">세션 사용자</div><div class="value">${state.session?.fullName || "-"}</div></div>
</div>
<div class="panel" style="margin-top: 18px;">
<h3>리포트 산출물</h3>
${table(
[
{ id: "id", text: "산출물ID" },
{ id: "runId", text: "실행ID" },
{ id: "artifactType", text: "형식" },
{ id: "downloadName", text: "파일명" },
{ id: "createdAt", text: "생성시각" }
],
artifacts,
{
render(column, value, row) {
if (column.id === "downloadName") {
return `<a href="/api/reports/${row.id}/download">${value}</a>`;
}
return value;
}
}
)}
</div>
<div class="panel">
<h3>최근 배치 로그</h3>
${table(
[
{ id: "id", text: "로그ID" },
{ id: "jobType", text: "작업유형" },
{ id: "referenceId", text: "참조ID" },
{ id: "logLevel", text: "레벨" },
{ id: "logMessage", text: "메시지" },
{ id: "createdAt", text: "생성시각" }
],
logs,
{
render(column, value) {
if (column.id === "logLevel") {
return pill(value);
}
return value;
}
}
)}
</div>
`;
}
function shellContent(content) {
return `
<div class="shell">
<aside class="sidebar">
<div class="brand">
<h1>Hanwha Nexacro Demo</h1>
<p>Spec driven preview generated from Nexacro DSL</p>
</div>
<div class="nav-list">${renderNav()}</div>
<div class="panel" style="margin-top: 18px;">
<h3 style="margin-top:0;">세션</h3>
<div class="muted">${state.session ? `${state.session.fullName} / ${state.session.roleCode}` : "로그인 필요"}</div>
<div class="row actions" style="margin-top: 12px;">
<button class="secondary" data-action="logout" ${state.session ? "" : "disabled"}>로그아웃</button>
</div>
</div>
</aside>
<main class="main">
<div class="topbar">
<div></div>
<div class="footer-links">
<a href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
${content}
</main>
</div>
`;
}
function render() {
let content = "";
switch (state.currentRoute) {
case "login":
content = renderLogin();
break;
case "master":
content = renderMaster();
break;
case "uploads":
content = renderUploads();
break;
case "consolidation":
content = renderConsolidation();
break;
case "reports":
content = renderReports();
break;
default:
content = "<div class='panel'>정의되지 않은 화면입니다.</div>";
}
document.getElementById("app").innerHTML = shellContent(content);
bindEvents();
}
async function loadSession() {
try {
state.session = await api("/api/auth/me");
} catch (error) {
state.session = null;
}
}
async function loadMaster() {
if (!state.session) return;
state.master = await api("/api/tx/master/reference");
}
async function loadUploads() {
if (!state.session) return;
state.uploads = await api("/api/tx/uploads/overview");
}
async function loadRuns() {
if (!state.session) return;
state.runs = await api("/api/tx/consolidations/overview");
}
async function loadReports() {
if (!state.session) return;
state.reports = await api("/api/tx/reports/overview");
}
async function refreshAll() {
await loadSession();
if (state.session) {
await Promise.all([loadMaster(), loadUploads(), loadRuns(), loadReports()]);
} else {
state.master = null;
state.uploads = null;
state.runs = null;
state.reports = null;
}
render();
}
function bindEvents() {
document.querySelectorAll("[data-route]").forEach((element) => {
element.addEventListener("click", async () => {
state.currentRoute = element.dataset.route;
if (state.currentRoute === "master") await loadMaster();
if (state.currentRoute === "uploads") await loadUploads();
if (state.currentRoute === "consolidation") await loadRuns();
if (state.currentRoute === "reports") await loadReports();
render();
});
});
const loginButton = document.querySelector("[data-action='login']");
if (loginButton) {
loginButton.addEventListener("click", async () => {
const username = document.getElementById("login-username").value;
const password = document.getElementById("login-password").value;
await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password })
});
state.currentRoute = "master";
await refreshAll();
});
}
const logoutButton = document.querySelector("[data-action='logout']");
if (logoutButton) {
logoutButton.addEventListener("click", async () => {
await api("/api/auth/logout", { method: "POST" });
state.currentRoute = "login";
await refreshAll();
});
}
const uploadButton = document.querySelector("[data-action='upload']");
if (uploadButton) {
uploadButton.addEventListener("click", async () => {
const templateCode = document.getElementById("upload-template").value;
const fiscalPeriod = document.getElementById("upload-period").value;
const file = document.getElementById("upload-file").files[0];
if (!file) {
alert("업로드할 파일을 선택하세요.");
return;
}
const formData = new FormData();
formData.append("templateCode", templateCode);
formData.append("fiscalPeriod", fiscalPeriod);
formData.append("file", file);
await api("/api/uploads", { method: "POST", body: formData });
await loadUploads();
render();
});
}
const reloadUploadsButton = document.querySelector("[data-action='reload-uploads']");
if (reloadUploadsButton) {
reloadUploadsButton.addEventListener("click", async () => {
await loadUploads();
render();
});
}
const runButton = document.querySelector("[data-action='request-run']");
if (runButton) {
runButton.addEventListener("click", async () => {
const fiscalPeriod = document.getElementById("run-period").value;
await api("/api/consolidations/runs", {
method: "POST",
body: JSON.stringify({ fiscalPeriod, reportCurrency: "KRW" })
});
await loadRuns();
render();
});
}
const reloadRunsButton = document.querySelector("[data-action='reload-runs']");
if (reloadRunsButton) {
reloadRunsButton.addEventListener("click", async () => {
await loadRuns();
await loadReports();
render();
});
}
}
refreshAll().catch((error) => {
console.error(error);
document.getElementById("app").innerHTML = `<div class="panel"><h3>초기화 실패</h3><p>${error.message}</p></div>`;
});

View File

@ -0,0 +1,67 @@
window.HANWHA_FORMS = [
{
"formId": "frmConsolidation",
"title": "집계 실행",
"route": "consolidation",
"authority": "OPERATOR",
"messages": [
{
"code": "RUN_HINT",
"level": "INFO",
"text": "업로드가 ACCEPTED 상태인 파일만 집계 대상에 포함됩니다."
}
]
},
{
"formId": "frmLogin",
"title": "로그인",
"route": "login",
"authority": "PUBLIC",
"messages": [
{
"code": "LOGIN_HINT",
"level": "INFO",
"text": "기본 계정 admin/operator/viewer, 비밀번호 demo1234"
}
]
},
{
"formId": "frmMasterData",
"title": "기준정보 관리",
"route": "master",
"authority": "VIEWER",
"messages": [
{
"code": "MASTER_LOAD",
"level": "INFO",
"text": "기준정보, 계정, 환율, 지분율을 확인합니다."
}
]
},
{
"formId": "frmReportsOps",
"title": "리포트/운영",
"route": "reports",
"authority": "VIEWER",
"messages": [
{
"code": "REPORT_HINT",
"level": "INFO",
"text": "batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다."
}
]
},
{
"formId": "frmUploadValidation",
"title": "업로드/검증",
"route": "uploads",
"authority": "OPERATOR",
"messages": [
{
"code": "UPLOAD_HINT",
"level": "INFO",
"text": "invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."
}
]
}
];

View File

@ -0,0 +1,264 @@
:root {
--bg: #f6f8fb;
--surface: #ffffff;
--surface-alt: #eef4ff;
--line: #d8dfeb;
--text: #10203a;
--muted: #5c6d86;
--accent: #f57c23;
--accent-soft: #ffe6d1;
--blue: #1f5fbf;
--danger: #c53b3b;
--success: #237c52;
--shadow: 0 16px 40px rgba(16, 32, 58, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Pretendard", "Noto Sans KR", sans-serif;
background:
radial-gradient(circle at top right, rgba(245, 124, 35, 0.15), transparent 18rem),
linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%);
color: var(--text);
}
a { color: var(--blue); text-decoration: none; }
button, input, select { font: inherit; }
button {
cursor: pointer;
border: 0;
border-radius: 12px;
padding: 12px 16px;
background: var(--accent);
color: #fff;
font-weight: 700;
}
button.secondary {
background: #fff;
color: var(--text);
border: 1px solid var(--line);
}
button.ghost {
background: var(--surface-alt);
color: var(--blue);
}
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
background: #fff;
}
.shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 28px 22px;
background: rgba(255, 255, 255, 0.8);
border-right: 1px solid rgba(216, 223, 235, 0.7);
backdrop-filter: blur(16px);
}
.brand {
margin-bottom: 28px;
}
.brand h1 {
margin: 0;
font-size: 28px;
line-height: 1.1;
}
.brand p {
margin: 10px 0 0;
color: var(--muted);
font-size: 14px;
}
.nav-list {
display: grid;
gap: 10px;
}
.nav-item {
padding: 14px 16px;
border-radius: 14px;
background: transparent;
border: 1px solid transparent;
text-align: left;
color: var(--text);
}
.nav-item.active {
background: var(--surface-alt);
border-color: #c9dafd;
}
.main {
padding: 28px;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 24px;
}
.topbar h2 {
margin: 0;
font-size: 32px;
}
.status-card,
.panel,
.hero-card {
background: var(--surface);
border: 1px solid rgba(216, 223, 235, 0.7);
border-radius: 24px;
box-shadow: var(--shadow);
}
.hero-card {
padding: 26px;
margin-bottom: 24px;
background:
linear-gradient(135deg, rgba(245, 124, 35, 0.08), rgba(31, 95, 191, 0.08)),
var(--surface);
}
.hero-card p {
margin: 8px 0 0;
color: var(--muted);
}
.grid-2,
.grid-3,
.grid-4 {
display: grid;
gap: 18px;
}
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.status-card {
padding: 20px;
}
.status-card .label {
font-size: 13px;
color: var(--muted);
}
.status-card .value {
font-size: 30px;
margin-top: 10px;
font-weight: 800;
}
.panel {
padding: 22px;
margin-bottom: 18px;
}
.panel h3 {
margin-top: 0;
margin-bottom: 14px;
}
.stack {
display: grid;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: 140px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.row label {
font-size: 14px;
color: var(--muted);
}
.row.actions {
grid-template-columns: 1fr;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.notice {
border-radius: 16px;
padding: 14px 16px;
background: var(--accent-soft);
color: #7a4313;
margin-bottom: 16px;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 12px 10px;
border-bottom: 1px solid #ecf0f6;
font-size: 14px;
}
th {
color: var(--muted);
font-weight: 700;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.pill.ACCEPTED, .pill.SUCCESS { background: rgba(35, 124, 82, 0.12); color: var(--success); }
.pill.REJECTED, .pill.ERROR, .pill.FAILED { background: rgba(197, 59, 59, 0.12); color: var(--danger); }
.pill.REQUESTED, .pill.PROCESSING, .pill.INFO { background: rgba(31, 95, 191, 0.12); color: var(--blue); }
.login-box {
max-width: 420px;
}
.muted {
color: var(--muted);
font-size: 14px;
}
.footer-links {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
@media (max-width: 1100px) {
.shell { grid-template-columns: 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid rgba(216, 223, 235, 0.7); }
.grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; }
}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hanwha Nexacro Demo Preview</title>
<link rel="stylesheet" href="/assets/styles.css" />
</head>
<body>
<div id="app"></div>
<script src="/assets/app.js" defer></script>
</body>
</html>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<Project version="2.0" name="HanwhaNexacroDemo">
<Environment url="./environment.xml"/>
<TypeDefinition url="./typedefinition.xml"/>
<AppVariables url="./appvariables.xml"/>
<Application url="./application.xadl"/>
<Forms>
<Form id="frmConsolidation" url="./forms/frmConsolidation.xfdl" title="집계 실행"/>
<Form id="frmLogin" url="./forms/frmLogin.xfdl" title="로그인"/>
<Form id="frmMasterData" url="./forms/frmMasterData.xfdl" title="기준정보 관리"/>
<Form id="frmReportsOps" url="./forms/frmReportsOps.xfdl" title="리포트/운영"/>
<Form id="frmUploadValidation" url="./forms/frmUploadValidation.xfdl" title="업로드/검증"/>
</Forms>
</Project>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Application id="hanwha.demo.app" titletext="Hanwha Nexacro Demo" mainframeurl="./frame/MainFrame.xfdl">
<Frames>
<MainFrame id="mainframe" left="0" top="0" width="1440" height="900" formurl="./forms/frmLogin.xfdl"/>
</Frames>
</Application>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<AppVariables>
<Variable id="g_apiBase" value="/api"/>
<Variable id="g_appTitle" value="Hanwha Nexacro Demo"/>
</AppVariables>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<Environment version="2.0" id="environment" themeid="theme::demo">
<Screen id="desktop" width="1440" height="900"/>
<Services>
<Service id="svcApi" url="/api" type="http"/>
<Service id="svcFile" url="/api" type="http"/>
</Services>
</Environment>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<FDL version="2.0">
<Form id="frmConsolidation" titletext="집계 실행" width="1440" height="900" onload="this.form_onload();">
<Objects>
<Dataset id="dsRuns">
<ColumnInfo>
<Column id="id" type="INT" size="256"/>
<Column id="fiscalPeriod" type="STRING" size="256"/>
<Column id="statusCode" type="STRING" size="256"/>
<Column id="requestedBy" type="STRING" size="256"/>
<Column id="requestedAt" type="STRING" size="256"/>
<Column id="finishedAt" type="STRING" size="256"/>
<Column id="summaryMessage" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
</Objects>
<Layouts>
<Layout id="default" width="1440" height="900"/>
</Layouts>
<Script><![CDATA[
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};
this.form_onload = function()
{
this.gfnShowMessage("집계 실행 loaded");
};
this.txLoadRuns = function()
{
this.gfnShowMessage("txLoadRuns -> /api/tx/consolidations/overview");
};
this.actRunConsolidation = function()
{
this.gfnShowMessage("집계 실행");
};
]]></Script>
<Edit id="edtRunFiscalPeriod" left="36" top="108" width="180" height="38" displaynulltext="회계기간 (YYYY-MM)"/>
<Button id="btnRun" left="236" top="108" width="140" height="38" text="집계 실행"/>
<Button id="btnReloadRuns" left="392" top="108" width="160" height="38" text="상태 새로고침"/>
<Static id="sta_grdRuns" left="36" top="148" width="1368" height="24" text="집계 실행 이력"/>
<Grid id="grdRuns" left="36" top="176" width="1368" height="360" binddataset="dsRuns">
<Formats><Format id="default"><Columns><Column size="195"/><Column size="195"/><Column size="195"/><Column size="195"/><Column size="195"/><Column size="195"/><Column size="195"/></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="상태"/><Cell col="3" text="요청자"/><Cell col="4" text="요청시각"/><Cell col="5" text="완료시각"/><Cell col="6" text="요약"/></Band><Band id="body"><Cell col="0" text="bind:id"/><Cell col="1" text="bind:fiscalPeriod"/><Cell col="2" text="bind:statusCode"/><Cell col="3" text="bind:requestedBy"/><Cell col="4" text="bind:requestedAt"/><Cell col="5" text="bind:finishedAt"/><Cell col="6" text="bind:summaryMessage"/></Band></Format></Formats>
</Grid>
</Form>
</FDL>

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<FDL version="2.0">
<Form id="frmLogin" titletext="로그인" width="1440" height="900" onload="this.form_onload();">
<Objects>
<Dataset id="dsLogin">
<ColumnInfo>
<Column id="username" type="STRING" size="256"/>
<Column id="password" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
</Objects>
<Layouts>
<Layout id="default" width="1440" height="900"/>
</Layouts>
<Script><![CDATA[
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};
this.form_onload = function()
{
this.gfnShowMessage("로그인 loaded");
};
this.actLogin = function()
{
this.gfnShowMessage("로그인");
};
]]></Script>
<Static id="staTitle" left="88" top="72" width="460" height="64" text="Hanwha Nexacro Demo"/>
<Static id="staSubtitle" left="88" top="148" width="460" height="28" text="업로드/검증 중심 재무 통합 데모"/>
<Edit id="edtUsername" left="88" top="248" width="320" height="44" displaynulltext="사용자 ID" value="bind:username"/>
<Edit id="edtPassword" left="88" top="308" width="320" height="44" displaynulltext="비밀번호" value="bind:password" password="true"/>
<Button id="btnLogin" left="88" top="376" width="320" height="48" text="로그인"/>
</Form>
</FDL>

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<FDL version="2.0">
<Form id="frmMasterData" titletext="기준정보 관리" width="1440" height="900" onload="this.form_onload();">
<Objects>
<Dataset id="dsEntity">
<ColumnInfo>
<Column id="entityCode" type="STRING" size="256"/>
<Column id="entityName" type="STRING" size="256"/>
<Column id="baseCurrency" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
<Dataset id="dsAccount">
<ColumnInfo>
<Column id="accountCode" type="STRING" size="256"/>
<Column id="accountName" type="STRING" size="256"/>
<Column id="accountCategory" type="STRING" size="256"/>
<Column id="internalTradeYn" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
<Dataset id="dsFxRate">
<ColumnInfo>
<Column id="fiscalPeriod" type="STRING" size="256"/>
<Column id="currencyCode" type="STRING" size="256"/>
<Column id="rateToKrw" type="BIGDECIMAL" size="256"/>
</ColumnInfo>
</Dataset>
<Dataset id="dsOwnership">
<ColumnInfo>
<Column id="parentEntityCode" type="STRING" size="256"/>
<Column id="childEntityCode" type="STRING" size="256"/>
<Column id="ownershipRatio" type="BIGDECIMAL" size="256"/>
</ColumnInfo>
</Dataset>
</Objects>
<Layouts>
<Layout id="default" width="1440" height="900"/>
</Layouts>
<Script><![CDATA[
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};
this.form_onload = function()
{
this.gfnShowMessage("기준정보 관리 loaded");
};
this.txLoadReference = function()
{
this.gfnShowMessage("txLoadReference -> /api/tx/master/reference");
};
]]></Script>
<Static id="sta_grdEntity" left="36" top="80" width="640" height="24" text="법인정보"/>
<Grid id="grdEntity" left="36" top="108" width="640" height="260" binddataset="dsEntity">
<Formats><Format id="default"><Columns><Column size="213"/><Column size="213"/><Column size="213"/></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="기준통화"/></Band><Band id="body"><Cell col="0" text="bind:entityCode"/><Cell col="1" text="bind:entityName"/><Cell col="2" text="bind:baseCurrency"/></Band></Format></Formats>
</Grid>
<Static id="sta_grdAccount" left="708" top="80" width="696" height="24" text="계정코드"/>
<Grid id="grdAccount" left="708" top="108" width="696" height="260" binddataset="dsAccount">
<Formats><Format id="default"><Columns><Column size="174"/><Column size="174"/><Column size="174"/><Column size="174"/></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:accountCode"/><Cell col="1" text="bind:accountName"/><Cell col="2" text="bind:accountCategory"/><Cell col="3" text="bind:internalTradeYn"/></Band></Format></Formats>
</Grid>
<Static id="sta_grdFxRate" left="36" top="382" width="640" height="24" text="환율정보"/>
<Grid id="grdFxRate" left="36" top="410" width="640" height="260" binddataset="dsFxRate">
<Formats><Format id="default"><Columns><Column size="213"/><Column size="213"/><Column size="213"/></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="KRW 환산율"/></Band><Band id="body"><Cell col="0" text="bind:fiscalPeriod"/><Cell col="1" text="bind:currencyCode"/><Cell col="2" text="bind:rateToKrw"/></Band></Format></Formats>
</Grid>
<Static id="sta_grdOwnership" left="708" top="382" width="696" height="24" text="지분율"/>
<Grid id="grdOwnership" left="708" top="410" width="696" height="260" binddataset="dsOwnership">
<Formats><Format id="default"><Columns><Column size="232"/><Column size="232"/><Column size="232"/></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="지분율"/></Band><Band id="body"><Cell col="0" text="bind:parentEntityCode"/><Cell col="1" text="bind:childEntityCode"/><Cell col="2" text="bind:ownershipRatio"/></Band></Format></Formats>
</Grid>
</Form>
</FDL>

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<FDL version="2.0">
<Form id="frmReportsOps" titletext="리포트/운영" width="1440" height="900" onload="this.form_onload();">
<Objects>
<Dataset id="dsArtifacts">
<ColumnInfo>
<Column id="id" type="INT" size="256"/>
<Column id="runId" type="INT" size="256"/>
<Column id="artifactType" type="STRING" size="256"/>
<Column id="downloadName" type="STRING" size="256"/>
<Column id="createdAt" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
<Dataset id="dsJobLogs">
<ColumnInfo>
<Column id="id" type="INT" size="256"/>
<Column id="jobType" type="STRING" size="256"/>
<Column id="referenceId" type="INT" size="256"/>
<Column id="logLevel" type="STRING" size="256"/>
<Column id="logMessage" type="STRING" size="256"/>
<Column id="createdAt" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
</Objects>
<Layouts>
<Layout id="default" width="1440" height="900"/>
</Layouts>
<Script><![CDATA[
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};
this.form_onload = function()
{
this.gfnShowMessage("리포트/운영 loaded");
};
this.txLoadReports = function()
{
this.gfnShowMessage("txLoadReports -> /api/tx/reports/overview");
};
]]></Script>
<Static id="sta_grdArtifacts" left="36" top="80" width="1368" height="24" text="리포트 산출물"/>
<Grid id="grdArtifacts" left="36" top="108" width="1368" height="270" binddataset="dsArtifacts">
<Formats><Format id="default"><Columns><Column size="273"/><Column size="273"/><Column size="273"/><Column size="273"/><Column size="273"/></Columns><Rows><Row size="32" band="head"/><Row size="28"/></Rows><Band id="head"><Cell col="0" text="산출물ID"/><Cell col="1" text="실행ID"/><Cell col="2" text="형식"/><Cell col="3" text="파일명"/><Cell col="4" text="생성시각"/></Band><Band id="body"><Cell col="0" text="bind:id"/><Cell col="1" text="bind:runId"/><Cell col="2" text="bind:artifactType"/><Cell col="3" text="bind:downloadName"/><Cell col="4" text="bind:createdAt"/></Band></Format></Formats>
</Grid>
<Static id="sta_grdJobLogs" left="36" top="392" width="1368" height="24" text="최근 배치 로그"/>
<Grid id="grdJobLogs" 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>
</Form>
</FDL>

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<FDL version="2.0">
<Form id="frmUploadValidation" titletext="업로드/검증" width="1440" height="900" onload="this.form_onload();">
<Objects>
<Dataset id="dsUploadBatches">
<ColumnInfo>
<Column id="id" type="INT" size="256"/>
<Column id="templateCode" type="STRING" size="256"/>
<Column id="fiscalPeriod" type="STRING" size="256"/>
<Column id="statusCode" type="STRING" size="256"/>
<Column id="originalFilename" type="STRING" size="256"/>
<Column id="rowCount" type="INT" size="256"/>
<Column id="errorCount" type="INT" size="256"/>
<Column id="uploadedAt" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
<Dataset id="dsIssues">
<ColumnInfo>
<Column id="rowNumber" type="INT" size="256"/>
<Column id="issueCode" type="STRING" size="256"/>
<Column id="issueMessage" type="STRING" size="256"/>
<Column id="severityCode" type="STRING" size="256"/>
</ColumnInfo>
</Dataset>
</Objects>
<Layouts>
<Layout id="default" width="1440" height="900"/>
</Layouts>
<Script><![CDATA[
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};
this.form_onload = function()
{
this.gfnShowMessage("업로드/검증 loaded");
};
this.txLoadUploadOverview = function()
{
this.gfnShowMessage("txLoadUploadOverview -> /api/tx/uploads/overview");
};
this.actUpload = function()
{
this.gfnShowMessage("파일 업로드");
};
]]></Script>
<Combo id="cboTemplate" left="36" top="108" width="220" height="38" displaynulltext="템플릿 구분" codecolumn="code" datacolumn="label"/>
<Edit id="edtFiscalPeriod" left="274" top="108" width="140" height="38" displaynulltext="회계기간 (YYYY-MM)"/>
<FileUpload id="fileUpload" left="432" top="108" width="480" height="38" displaynulltext="업로드 파일"/>
<Button id="btnUpload" left="930" top="108" width="140" height="38" text="파일 업로드"/>
<Button id="btnReloadUploads" left="1086" top="108" width="140" height="38" text="내역 새로고침"/>
<Static id="sta_grdUploadBatches" left="36" top="148" width="1368" height="24" text="업로드 이력"/>
<Grid id="grdUploadBatches" left="36" top="176" width="1368" height="270" binddataset="dsUploadBatches">
<Formats><Format id="default"><Columns><Column size="171"/><Column size="171"/><Column size="171"/><Column size="171"/><Column size="171"/><Column size="171"/><Column size="171"/><Column size="171"/></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="회계기간"/><Cell col="3" text="상태"/><Cell col="4" text="파일명"/><Cell col="5" text="건수"/><Cell col="6" text="오류건수"/><Cell col="7" text="업로드시각"/></Band><Band id="body"><Cell col="0" text="bind:id"/><Cell col="1" text="bind:templateCode"/><Cell col="2" text="bind:fiscalPeriod"/><Cell col="3" text="bind:statusCode"/><Cell col="4" text="bind:originalFilename"/><Cell col="5" text="bind:rowCount"/><Cell col="6" text="bind:errorCount"/><Cell col="7" text="bind:uploadedAt"/></Band></Format></Formats>
</Grid>
<Static id="sta_grdIssues" left="36" top="458" width="1368" height="24" text="오류내역"/>
<Grid id="grdIssues" 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>
</Form>
</FDL>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<FDL version="2.0">
<Form id="MainFrame" titletext="Hanwha Nexacro Demo" width="1440" height="900">
<Static id="staTitle" left="24" top="18" width="500" height="36" text="Hanwha Nexacro Demo"/>
<Static id="staGuide" left="24" top="60" width="900" height="24" text="이 MainFrame은 AI generator가 만든 Nexacro skeleton입니다."/>
</Form>
</FDL>

View File

@ -0,0 +1,15 @@
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<TypeDefinition version="2.0">
<Components>
<Component id="Button" classname="nexacro.Button"/>
<Component id="Static" classname="nexacro.Static"/>
<Component id="Edit" classname="nexacro.Edit"/>
<Component id="Div" classname="nexacro.Div"/>
<Component id="Grid" classname="nexacro.Grid"/>
<Component id="Combo" classname="nexacro.Combo"/>
<Component id="FileUpload" classname="nexacro.FileUpload"/>
</Components>
</TypeDefinition>

104
docker-compose.yml Normal file
View File

@ -0,0 +1,104 @@
services:
db:
image: postgres:16-alpine
container_name: hanwha-demo-db
environment:
POSTGRES_DB: hanwha_demo
POSTGRES_USER: hanwha
POSTGRES_PASSWORD: hanwha1234
ports:
- "15432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hanwha -d hanwha_demo"]
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
container_name: hanwha-demo-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "19000:9000"
- "19001:9001"
volumes:
- minio-data:/data
api:
build:
context: .
dockerfile: server/api/Dockerfile
image: hanwha-nexacro-api:local
container_name: hanwha-demo-api
environment:
DB_URL: jdbc:postgresql://db:5432/hanwha_demo
DB_USERNAME: hanwha
DB_PASSWORD: hanwha1234
STORAGE_PROVIDER: minio
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET: reports
depends_on:
db:
condition: service_healthy
minio:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:8080/actuator/health | grep UP >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 5s
retries: 20
batch:
image: hanwha-nexacro-api:local
container_name: hanwha-demo-batch
environment:
SPRING_PROFILES_ACTIVE: batch
DB_URL: jdbc:postgresql://db:5432/hanwha_demo
DB_USERNAME: hanwha
DB_PASSWORD: hanwha1234
STORAGE_PROVIDER: minio
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_BUCKET: reports
BATCH_WORKER_NAME: batch-1
depends_on:
db:
condition: service_healthy
minio:
condition: service_started
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:8080/actuator/health | grep UP >/dev/null 2>&1 || exit 1"]
interval: 10s
timeout: 5s
retries: 20
proxy:
image: nginx:1.27-alpine
container_name: hanwha-demo-proxy
depends_on:
api:
condition: service_healthy
ports:
- "8080:80"
volumes:
- ./ops/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./client/nexacro-deploy:/usr/share/nginx/html:ro
- ./sample-data:/usr/share/nginx/html/sample-data:ro
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 20
volumes:
db-data:
minio-data:

22
docs/admin-guide.md Normal file
View File

@ -0,0 +1,22 @@
# Admin Guide
## 기본 계정
- `admin`: 모든 기능 사용 가능
- `operator`: 업로드, 집계 실행 가능
- `viewer`: 조회, 다운로드 가능
## 주요 엔드포인트
- `POST /api/auth/login`
- `GET /api/tx/master/reference`
- `POST /api/uploads`
- `POST /api/consolidations/runs`
- `GET /api/reports/{id}/download`
- `GET /api/jobs`
## 샘플 템플릿
- `GET /api/uploads/templates/trial-balance/download`
- `GET /api/uploads/templates/forecast/download`

View File

@ -0,0 +1,22 @@
# Nexacro Windows Build Guide
## 목적
`spec/nexacro` 명세에서 생성된 `client/nexacro-src`를 Windows의 Nexacro Studio에서 열어 검수하고 웹 배포 산출물을 만드는 절차다.
## 절차
1. 저장소에서 최신 `client/nexacro-src`를 받는다.
2. Windows에서 Nexacro Studio 실행
3. `HanwhaNexacroDemo.xprj`를 연다.
4. Form 로드 여부와 레이아웃을 확인한다.
5. 필요한 미세조정 후 다시 spec에 역반영한다.
6. Studio의 웹 배포 기능으로 산출물을 생성한다.
7. 생성된 웹 산출물을 `client/nexacro-deploy`에 반영한다.
## 운영 원칙
- 임의의 Studio 수정본을 기준 소스로 삼지 않는다.
- 최종 기준은 `spec/nexacro`와 generator다.
- Studio 조정이 필요하면 spec이나 template에 반영한 후 다시 생성한다.

22
docs/runbook.md Normal file
View File

@ -0,0 +1,22 @@
# Runbook
## 초기 실행
1. `cd tools/nexacro-gen && npm install && npm run generate`
2. `python3 scripts/generate_sample_uploads.py`
3. `docker compose up --build`
4. `http://localhost:8080` 접속 후 `admin / demo1234` 로그인
5. 필요 시 MinIO console은 `http://localhost:19001`에서 확인
## 권장 데모 순서
1. `기준정보 관리`에서 법인, 계정, 환율, 지분율 확인
2. `업로드/검증`에서 `trial-balance-invalid.xlsx` 업로드 후 오류 확인
3. `trial-balance-valid.xlsx`, `forecast-valid.xlsx` 순서로 업로드
4. `집계 실행`에서 `2026-03` 실행
5. `리포트/운영`에서 Excel/PDF 다운로드와 배치 로그 확인
## 재실행
- 전체 정리: `docker compose down -v`
- DB만 유지한 채 재기동: `docker compose restart`

25
docs/spec-dsl.md Normal file
View File

@ -0,0 +1,25 @@
# Nexacro Spec DSL
## 파일 위치
- `spec/nexacro/app.yaml`
- `spec/nexacro/*.yaml`
## 필드
- `formId`: Nexacro Form ID
- `title`: 화면명
- `route`: preview 라우트 키
- `authority`: `PUBLIC`, `VIEWER`, `OPERATOR`, `ADMIN`
- `layout`: width/height
- `datasets`: dataset 정의
- `components`: Edit, Button, Static, Combo, FileUpload 등 기본 컴포넌트
- `grids`: dataset 기반 표
- `transactions`: 조회용 API
- `actions`: 저장/실행/업로드 액션
- `messages`: 화면 설명
## 생성 결과
- `client/nexacro-src`: Nexacro project skeleton
- `client/nexacro-deploy`: preview UI

20
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,20 @@
# Troubleshooting
## Preview는 뜨는데 로그인 실패
- API health 확인: `curl http://localhost:8080/actuator/health`
- 세션 쿠키는 같은 origin 기준으로 전달되므로 reverse proxy를 우회하지 않는 것이 안전하다.
## 업로드가 계속 REJECTED
- `META` 시트의 `templateCode`, `templateVersion` 확인
- `DATA` 시트 헤더 순서 확인
- `trial-balance`는 차변/대변 합계가 동일해야 한다.
- 내부거래 계정 `AR_INT`, `AP_INT`는 상대법인코드가 필요하다.
## 집계가 SUCCESS로 바뀌지 않음
- `GET /api/jobs`로 최근 로그 확인
- `batch` 컨테이너 health 확인
- ACCEPTED 상태의 `trial-balance` 업로드가 존재하는지 확인

View File

@ -0,0 +1,22 @@
# Vendor Substitution
## 로컬 데모에서 대체한 항목
- `SAP HANA 2.0` -> `PostgreSQL 16`
- `JEUS` -> `Spring Boot embedded container`
- `WebtoB` -> `Nginx`
- `Nexacro Studio 수작업 화면 저작` -> `spec + generator 기반 source 생성`
## 치환 포인트
- DB dialect: `server/api/src/main/resources/db/migration`
- reverse proxy: `ops/nginx/default.conf`
- UI deploy path: `client/nexacro-deploy`
- Nexacro source: `client/nexacro-src`
## 주의점
- HANA 전환성을 위해 Postgres 전용 기능 사용을 피했다.
- Nexacro runtime 자체는 이 저장소에 포함되어 있지 않다.
- Windows Studio 검수 후 실제 배포 산출물을 `client/nexacro-deploy`에 덮어쓸 수 있다.

37
ops/nginx/default.conf Normal file
View File

@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
default_type text/plain;
return 200 'ok';
}
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /actuator/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /sample-data/ {
alias /usr/share/nginx/html/sample-data/;
autoindex on;
}
location / {
try_files $uri $uri/ /index.html;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,69 @@
from pathlib import Path
from openpyxl import Workbook
ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "sample-data"
def write_workbook(template_code: str, rows: list[list[str]], filename: str) -> None:
workbook = Workbook()
meta_sheet = workbook.active
meta_sheet.title = "META"
meta_sheet.append(["key", "value"])
meta_sheet.append(["templateCode", template_code])
meta_sheet.append(["templateVersion", "1.0"])
data_sheet = workbook.create_sheet("DATA")
if template_code == "trial-balance":
headers = ["fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount"]
else:
headers = ["fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue"]
data_sheet.append(headers)
for row in rows:
data_sheet.append(row)
workbook.save(OUT_DIR / filename)
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
write_workbook(
"trial-balance",
[
["2026-03", "US1", "AR_INT", "", "USD", "100", "0"],
["2026-03", "HQ", "REV_EXT", "", "KRW", "0", "70"],
],
"trial-balance-invalid.xlsx",
)
write_workbook(
"trial-balance",
[
["2026-03", "US1", "AR_INT", "HQ", "USD", "100", "0"],
["2026-03", "HQ", "AP_INT", "US1", "KRW", "0", "100"],
["2026-03", "HQ", "REV_EXT", "", "KRW", "0", "200000"],
["2026-03", "HQ", "EXP_OPEX", "", "KRW", "200000", "0"],
],
"trial-balance-valid.xlsx",
)
write_workbook(
"forecast",
[
["2026-03", "US1", "REV_EXT", "USD", "PLAN", "500"],
["2026-03", "SG1", "EXP_OPEX", "SGD", "FORECAST", "100"],
],
"forecast-valid.xlsx",
)
write_workbook("trial-balance", [], "trial-balance-template.xlsx")
write_workbook("forecast", [], "forecast-template.xlsx")
if __name__ == "__main__":
main()

17
server/api/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM gradle:8.10.2-jdk21-alpine AS builder
WORKDIR /workspace
COPY server/api/settings.gradle settings.gradle
COPY server/api/build.gradle build.gradle
COPY server/api/src src
RUN gradle --no-daemon bootJar
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /workspace/build/libs/*.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

44
server/api/build.gradle Normal file
View File

@ -0,0 +1,44 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.hanwha'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.apache.poi:poi-ooxml:5.2.5'
implementation 'io.minio:minio:8.5.12'
implementation 'com.github.librepdf:openpdf:1.3.39'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'com.h2database:h2'
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.compilerArgs += ['-parameters']
}

View File

@ -0,0 +1,2 @@
rootProject.name = "hanwha-nexacro-demo-api"

View File

@ -0,0 +1,17 @@
package com.hanwha.nexacrodemo;
import org.mybatis.spring.annotation.MapperScan;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@MapperScan(basePackages = "com.hanwha.nexacrodemo", annotationClass = Mapper.class)
public class HanwhaNexacroDemoApplication {
public static void main(String[] args) {
SpringApplication.run(HanwhaNexacroDemoApplication.class, args);
}
}

View File

@ -0,0 +1,38 @@
package com.hanwha.nexacrodemo.auth;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
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("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public Map<String, Object> login(@Valid @RequestBody LoginRequest request, HttpSession session) {
return authService.login(request, session);
}
@PostMapping("/logout")
public Map<String, Object> logout(HttpSession session) {
authService.logout(session);
return Map.of("ok", true);
}
@GetMapping("/me")
public Map<String, Object> me(HttpSession session) {
return authService.me(session);
}
}

View File

@ -0,0 +1,70 @@
package com.hanwha.nexacrodemo.auth;
import com.hanwha.nexacrodemo.common.ApiException;
import jakarta.servlet.http.HttpSession;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserMapper userMapper;
public AuthService(UserMapper userMapper) {
this.userMapper = userMapper;
}
public Map<String, Object> login(LoginRequest request, HttpSession session) {
UserAccount account = userMapper.findByUsername(request.getUsername());
if (account == null || !"Y".equals(account.getActiveYn()) || !account.getPasswordText().equals(request.getPassword())) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "사용자 ID 또는 비밀번호가 올바르지 않습니다.");
}
SessionUser sessionUser = new SessionUser(account.getUsername(), account.getFullName(), account.getRoleCode());
session.setAttribute(SessionUser.SESSION_KEY, sessionUser);
return me(session);
}
public Map<String, Object> me(HttpSession session) {
SessionUser sessionUser = currentUser(session);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("username", sessionUser.getUsername());
payload.put("fullName", sessionUser.getFullName());
payload.put("roleCode", sessionUser.getRoleCode());
return payload;
}
public void logout(HttpSession session) {
session.invalidate();
}
public SessionUser currentUser(HttpSession session) {
Object sessionValue = session.getAttribute(SessionUser.SESSION_KEY);
if (!(sessionValue instanceof SessionUser sessionUser)) {
throw new ApiException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
return sessionUser;
}
public SessionUser requireRole(HttpSession session, String requiredRole) {
SessionUser sessionUser = currentUser(session);
if (rank(sessionUser.getRoleCode()) < rank(requiredRole)) {
throw new ApiException(HttpStatus.FORBIDDEN, "권한이 부족합니다.");
}
return sessionUser;
}
private int rank(String roleCode) {
return switch (roleCode) {
case "ADMIN" -> 30;
case "OPERATOR" -> 20;
case "VIEWER" -> 10;
case "PUBLIC" -> 0;
default -> -1;
};
}
}

View File

@ -0,0 +1,29 @@
package com.hanwha.nexacrodemo.auth;
import jakarta.validation.constraints.NotBlank;
public class ConsolidationRequest {
@NotBlank(message = "회계기간은 필수입니다.")
private String fiscalPeriod;
@NotBlank(message = "리포트 통화는 필수입니다.")
private String reportCurrency;
public String getFiscalPeriod() {
return fiscalPeriod;
}
public void setFiscalPeriod(String fiscalPeriod) {
this.fiscalPeriod = fiscalPeriod;
}
public String getReportCurrency() {
return reportCurrency;
}
public void setReportCurrency(String reportCurrency) {
this.reportCurrency = reportCurrency;
}
}

View File

@ -0,0 +1,29 @@
package com.hanwha.nexacrodemo.auth;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "사용자 ID는 필수입니다.")
private String username;
@NotBlank(message = "비밀번호는 필수입니다.")
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -0,0 +1,44 @@
package com.hanwha.nexacrodemo.auth;
public class SessionUser {
public static final String SESSION_KEY = "SESSION_USER";
private String username;
private String fullName;
private String roleCode;
public SessionUser() {
}
public SessionUser(String username, String fullName, String roleCode) {
this.username = username;
this.fullName = fullName;
this.roleCode = roleCode;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getRoleCode() {
return roleCode;
}
public void setRoleCode(String roleCode) {
this.roleCode = roleCode;
}
}

View File

@ -0,0 +1,60 @@
package com.hanwha.nexacrodemo.auth;
public class UserAccount {
private Long id;
private String username;
private String passwordText;
private String fullName;
private String roleCode;
private String activeYn;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPasswordText() {
return passwordText;
}
public void setPasswordText(String passwordText) {
this.passwordText = passwordText;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getRoleCode() {
return roleCode;
}
public void setRoleCode(String roleCode) {
this.roleCode = roleCode;
}
public String getActiveYn() {
return activeYn;
}
public void setActiveYn(String activeYn) {
this.activeYn = activeYn;
}
}

View File

@ -0,0 +1,26 @@
package com.hanwha.nexacrodemo.auth;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Select("""
select id, username, password_text, full_name, role_code, active_yn
from app_user
where username = #{username}
""")
@Results(id = "userAccountMap", value = {
@Result(property = "id", column = "id"),
@Result(property = "username", column = "username"),
@Result(property = "passwordText", column = "password_text"),
@Result(property = "fullName", column = "full_name"),
@Result(property = "roleCode", column = "role_code"),
@Result(property = "activeYn", column = "active_yn")
})
UserAccount findByUsername(String username);
}

View File

@ -0,0 +1,26 @@
package com.hanwha.nexacrodemo.batch;
import com.hanwha.nexacrodemo.consolidation.ConsolidationService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(name = "app.batch.enabled", havingValue = "true")
public class BatchScheduler {
private final ConsolidationService consolidationService;
private final String workerName;
public BatchScheduler(ConsolidationService consolidationService, @Value("${app.batch.worker-name}") String workerName) {
this.consolidationService = consolidationService;
this.workerName = workerName;
}
@Scheduled(fixedDelayString = "${app.batch.poll-interval-ms:5000}")
public void poll() {
consolidationService.processPendingRuns(workerName);
}
}

View File

@ -0,0 +1,18 @@
package com.hanwha.nexacrodemo.common;
import org.springframework.http.HttpStatus;
public class ApiException extends RuntimeException {
private final HttpStatus status;
public ApiException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}

View File

@ -0,0 +1,39 @@
package com.hanwha.nexacrodemo.common;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<Map<String, Object>> handleApiException(ApiException exception) {
return ResponseEntity.status(exception.getStatus()).body(errorBody(exception.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException exception) {
FieldError fieldError = exception.getBindingResult().getFieldErrors().stream().findFirst().orElse(null);
String message = fieldError == null ? "입력값이 올바르지 않습니다." : fieldError.getDefaultMessage();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorBody(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpectedException(Exception exception) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorBody(exception.getMessage()));
}
private Map<String, Object> errorBody(String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("ok", false);
body.put("message", message);
return body;
}
}

View File

@ -0,0 +1,31 @@
package com.hanwha.nexacrodemo.common;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public final class HashUtils {
private HashUtils() {
}
public static String sha256(InputStream inputStream) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
StringBuilder builder = new StringBuilder();
for (byte item : digest.digest()) {
builder.append(String.format("%02x", item));
}
return builder.toString();
} catch (IOException | NoSuchAlgorithmException exception) {
throw new IllegalStateException("파일 체크섬 계산에 실패했습니다.", exception);
}
}
}

View File

@ -0,0 +1,47 @@
package com.hanwha.nexacrodemo.common;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class MapKeyUtils {
private MapKeyUtils() {
}
public static Map<String, Object> camelize(Map<String, Object> source) {
Map<String, Object> target = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
target.put(toCamelCase(entry.getKey()), entry.getValue());
}
return target;
}
public static List<Map<String, Object>> camelizeList(List<Map<String, Object>> source) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> item : source) {
result.add(camelize(item));
}
return result;
}
private static String toCamelCase(String source) {
StringBuilder builder = new StringBuilder();
boolean upperNext = false;
for (char current : source.toCharArray()) {
if (current == '_') {
upperNext = true;
continue;
}
if (upperNext) {
builder.append(Character.toUpperCase(current));
upperNext = false;
} else {
builder.append(current);
}
}
return builder.toString();
}
}

View File

@ -0,0 +1,51 @@
package com.hanwha.nexacrodemo.common;
import java.util.LinkedHashMap;
import java.util.Map;
public class TxResponse {
private boolean ok = true;
private String message = "OK";
private final Map<String, Object> variables = new LinkedHashMap<>();
private final Map<String, Object> datasets = new LinkedHashMap<>();
public static TxResponse ok() {
return new TxResponse();
}
public TxResponse addVariable(String key, Object value) {
this.variables.put(key, value);
return this;
}
public TxResponse addDataset(String key, Object value) {
this.datasets.put(key, value);
return this;
}
public boolean isOk() {
return ok;
}
public void setOk(boolean ok) {
this.ok = ok;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Map<String, Object> getVariables() {
return variables;
}
public Map<String, Object> getDatasets() {
return datasets;
}
}

View File

@ -0,0 +1,28 @@
package com.hanwha.nexacrodemo.config;
import com.hanwha.nexacrodemo.auth.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final AuthService authService;
public AuthInterceptor(AuthService authService) {
this.authService = authService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String uri = request.getRequestURI();
if (uri.startsWith("/api/auth/login") || uri.startsWith("/actuator/health") || uri.startsWith("/error")) {
return true;
}
authService.currentUser(request.getSession());
return true;
}
}

View File

@ -0,0 +1,21 @@
package com.hanwha.nexacrodemo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor).addPathPatterns("/api/**");
}
}

View File

@ -0,0 +1,53 @@
package com.hanwha.nexacrodemo.consolidation;
import com.hanwha.nexacrodemo.auth.AuthService;
import com.hanwha.nexacrodemo.auth.ConsolidationRequest;
import com.hanwha.nexacrodemo.auth.SessionUser;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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("/api")
public class ConsolidationController {
private final AuthService authService;
private final ConsolidationService consolidationService;
public ConsolidationController(AuthService authService, ConsolidationService consolidationService) {
this.authService = authService;
this.consolidationService = consolidationService;
}
@PostMapping("/consolidations/runs")
public Map<String, Object> requestRun(@Valid @RequestBody ConsolidationRequest request, HttpSession session) {
SessionUser sessionUser = authService.requireRole(session, "OPERATOR");
return consolidationService.requestRun(request, sessionUser);
}
@GetMapping("/consolidations/runs/{runId}")
public Map<String, Object> run(@PathVariable Long runId, HttpSession session) {
authService.requireRole(session, "VIEWER");
return consolidationService.getRun(runId);
}
@GetMapping("/jobs")
public List<Map<String, Object>> jobs(HttpSession session) {
authService.requireRole(session, "VIEWER");
return consolidationService.listJobs();
}
@PostMapping("/jobs/{runId}/retry")
public Map<String, Object> retry(@PathVariable Long runId, HttpSession session) {
SessionUser sessionUser = authService.requireRole(session, "OPERATOR");
return consolidationService.retry(runId, sessionUser);
}
}

View File

@ -0,0 +1,138 @@
package com.hanwha.nexacrodemo.consolidation;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface ConsolidationMapper {
@Insert("""
insert into consolidation_run (
fiscal_period,
status_code,
requested_by,
report_currency
) values (
#{fiscalPeriod},
#{statusCode},
#{requestedBy},
#{reportCurrency}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertRun(ConsolidationRunCommand command);
@Select("""
select id, fiscal_period, status_code, requested_by, requested_at, started_at, finished_at, report_currency, summary_message
from consolidation_run
order by id desc
fetch first 20 rows only
""")
List<Map<String, Object>> listRuns();
@Select("""
select id, fiscal_period, status_code, requested_by, requested_at, started_at, finished_at, report_currency, summary_message
from consolidation_run
where status_code = 'REQUESTED'
order by requested_at
fetch first 10 rows only
""")
List<Map<String, Object>> listRequestedRuns();
@Select("""
select id, fiscal_period, status_code, requested_by, requested_at, started_at, finished_at, report_currency, summary_message
from consolidation_run
where id = #{id}
""")
Map<String, Object> findRun(Long id);
@Update("""
update consolidation_run
set status_code = 'PROCESSING',
started_at = current_timestamp,
worker_name = #{workerName}
where id = #{id}
and status_code = 'REQUESTED'
""")
int claimRun(@Param("id") Long id, @Param("workerName") String workerName);
@Update("""
update consolidation_run
set status_code = 'SUCCESS',
finished_at = current_timestamp,
summary_message = #{summaryMessage}
where id = #{id}
""")
void markRunSuccess(@Param("id") Long id, @Param("summaryMessage") String summaryMessage);
@Update("""
update consolidation_run
set status_code = 'FAILED',
finished_at = current_timestamp,
summary_message = #{summaryMessage}
where id = #{id}
""")
void markRunFailure(@Param("id") Long id, @Param("summaryMessage") String summaryMessage);
@Insert("""
insert into elimination_entry (
run_id,
entity_code,
partner_entity_code,
account_code,
elimination_amount,
note
) values (
#{runId},
#{entityCode},
#{partnerEntityCode},
#{accountCode},
#{eliminationAmount},
#{note}
)
""")
void insertEliminationEntry(
@Param("runId") Long runId,
@Param("entityCode") String entityCode,
@Param("partnerEntityCode") String partnerEntityCode,
@Param("accountCode") String accountCode,
@Param("eliminationAmount") BigDecimal eliminationAmount,
@Param("note") String note
);
@Insert("""
insert into job_log (
job_type,
reference_id,
log_level,
log_message
) values (
#{jobType},
#{referenceId},
#{logLevel},
#{logMessage}
)
""")
void insertJobLog(
@Param("jobType") String jobType,
@Param("referenceId") Long referenceId,
@Param("logLevel") String logLevel,
@Param("logMessage") String logMessage
);
@Select("""
select id, job_type, reference_id, log_level, log_message, created_at
from job_log
order by id desc
fetch first 50 rows only
""")
List<Map<String, Object>> listJobLogs();
}

View File

@ -0,0 +1,32 @@
package com.hanwha.nexacrodemo.consolidation;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ConsolidationPayload {
private final List<Map<String, Object>> contributionRows = new ArrayList<>();
private final List<Map<String, Object>> eliminationRows = new ArrayList<>();
private final List<Map<String, Object>> forecastRows = new ArrayList<>();
private final Map<String, BigDecimal> metrics = new LinkedHashMap<>();
public List<Map<String, Object>> getContributionRows() {
return contributionRows;
}
public List<Map<String, Object>> getEliminationRows() {
return eliminationRows;
}
public List<Map<String, Object>> getForecastRows() {
return forecastRows;
}
public Map<String, BigDecimal> getMetrics() {
return metrics;
}
}

View File

@ -0,0 +1,51 @@
package com.hanwha.nexacrodemo.consolidation;
public class ConsolidationRunCommand {
private Long id;
private String fiscalPeriod;
private String statusCode;
private String requestedBy;
private String reportCurrency;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getFiscalPeriod() {
return fiscalPeriod;
}
public void setFiscalPeriod(String fiscalPeriod) {
this.fiscalPeriod = fiscalPeriod;
}
public String getStatusCode() {
return statusCode;
}
public void setStatusCode(String statusCode) {
this.statusCode = statusCode;
}
public String getRequestedBy() {
return requestedBy;
}
public void setRequestedBy(String requestedBy) {
this.requestedBy = requestedBy;
}
public String getReportCurrency() {
return reportCurrency;
}
public void setReportCurrency(String reportCurrency) {
this.reportCurrency = reportCurrency;
}
}

View File

@ -0,0 +1,190 @@
package com.hanwha.nexacrodemo.consolidation;
import com.hanwha.nexacrodemo.auth.ConsolidationRequest;
import com.hanwha.nexacrodemo.auth.SessionUser;
import com.hanwha.nexacrodemo.common.ApiException;
import com.hanwha.nexacrodemo.common.MapKeyUtils;
import com.hanwha.nexacrodemo.master.MasterDataService;
import com.hanwha.nexacrodemo.report.ReportService;
import com.hanwha.nexacrodemo.upload.UploadMapper;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
public class ConsolidationService {
private final ConsolidationMapper consolidationMapper;
private final UploadMapper uploadMapper;
private final MasterDataService masterDataService;
private final ReportService reportService;
public ConsolidationService(
ConsolidationMapper consolidationMapper,
UploadMapper uploadMapper,
MasterDataService masterDataService,
ReportService reportService
) {
this.consolidationMapper = consolidationMapper;
this.uploadMapper = uploadMapper;
this.masterDataService = masterDataService;
this.reportService = reportService;
}
public Map<String, Object> requestRun(ConsolidationRequest request, SessionUser sessionUser) {
ConsolidationRunCommand command = new ConsolidationRunCommand();
command.setFiscalPeriod(request.getFiscalPeriod());
command.setStatusCode("REQUESTED");
command.setRequestedBy(sessionUser.getUsername());
command.setReportCurrency(request.getReportCurrency());
consolidationMapper.insertRun(command);
consolidationMapper.insertJobLog("CONSOLIDATION", command.getId(), "INFO", "집계 요청이 등록되었습니다.");
return MapKeyUtils.camelize(consolidationMapper.findRun(command.getId()));
}
public Map<String, Object> getRun(Long runId) {
Map<String, Object> run = consolidationMapper.findRun(runId);
if (run == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "집계 실행 정보를 찾을 수 없습니다.");
}
return MapKeyUtils.camelize(run);
}
public List<Map<String, Object>> listJobs() {
return MapKeyUtils.camelizeList(consolidationMapper.listJobLogs());
}
public Map<String, Object> retry(Long runId, SessionUser sessionUser) {
Map<String, Object> run = getRun(runId);
if (!"FAILED".equals(String.valueOf(run.get("statusCode")))) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FAILED 상태의 집계만 재실행할 수 있습니다.");
}
ConsolidationRequest request = new ConsolidationRequest();
request.setFiscalPeriod(String.valueOf(run.get("fiscalPeriod")));
request.setReportCurrency(String.valueOf(run.get("reportCurrency")));
return requestRun(request, sessionUser);
}
public void processPendingRuns(String workerName) {
List<Map<String, Object>> requestedRuns = consolidationMapper.listRequestedRuns();
for (Map<String, Object> requestedRun : requestedRuns) {
Long runId = Long.valueOf(String.valueOf(requestedRun.get("id")));
int claimed = consolidationMapper.claimRun(runId, workerName);
if (claimed == 1) {
processClaimedRun(runId);
}
}
}
private void processClaimedRun(Long runId) {
Map<String, Object> run = getRun(runId);
String fiscalPeriod = String.valueOf(run.get("fiscalPeriod"));
try {
consolidationMapper.insertJobLog("CONSOLIDATION", runId, "INFO", "집계 처리를 시작합니다.");
ConsolidationPayload payload = computePayload(fiscalPeriod);
if (payload.getContributionRows().isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "집계 가능한 ACCEPTED trial-balance 업로드가 없습니다.");
}
for (Map<String, Object> elimination : payload.getEliminationRows()) {
consolidationMapper.insertEliminationEntry(
runId,
String.valueOf(elimination.get("entityCode")),
String.valueOf(elimination.get("partnerEntityCode")),
String.valueOf(elimination.get("accountCode")),
new BigDecimal(String.valueOf(elimination.get("eliminationAmount"))),
String.valueOf(elimination.get("note"))
);
}
reportService.generateReports(runId, fiscalPeriod, payload);
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) {
consolidationMapper.markRunFailure(runId, exception.getMessage());
consolidationMapper.insertJobLog("CONSOLIDATION", runId, "ERROR", exception.getMessage());
}
}
private ConsolidationPayload computePayload(String fiscalPeriod) {
List<Map<String, Object>> trialRows = MapKeyUtils.camelizeList(uploadMapper.listAcceptedRows("trial-balance", fiscalPeriod));
List<Map<String, Object>> forecastRows = MapKeyUtils.camelizeList(uploadMapper.listAcceptedRows("forecast", fiscalPeriod));
MasterDataService.ReferenceSnapshot snapshot = masterDataService.snapshot();
Map<String, BigDecimal> ownershipMap = snapshot.ownershipRates().stream()
.collect(Collectors.toMap(
item -> String.valueOf(item.get("childEntityCode")),
item -> new BigDecimal(String.valueOf(item.get("ownershipRatio")))
));
var internalAccounts = snapshot.internalTradeAccounts();
ConsolidationPayload payload = new ConsolidationPayload();
BigDecimal grossContribution = BigDecimal.ZERO;
BigDecimal eliminationAmount = BigDecimal.ZERO;
BigDecimal forecastAmount = BigDecimal.ZERO;
for (Map<String, Object> source : trialRows) {
String entityCode = String.valueOf(source.get("entityCode"));
String accountCode = String.valueOf(source.get("accountCode"));
String partnerEntityCode = source.get("partnerEntityCode") == null ? "" : String.valueOf(source.get("partnerEntityCode"));
BigDecimal translatedAmount = decimal(source.get("translatedAmount"));
BigDecimal ownershipRatio = ownershipMap.getOrDefault(entityCode, BigDecimal.ONE);
BigDecimal weightedAmount = translatedAmount.multiply(ownershipRatio).setScale(2, RoundingMode.HALF_UP);
boolean internal = internalAccounts.contains(accountCode);
BigDecimal finalAmount = internal ? BigDecimal.ZERO : weightedAmount;
Map<String, Object> contributionRow = new LinkedHashMap<>();
contributionRow.put("entityCode", entityCode);
contributionRow.put("accountCode", accountCode);
contributionRow.put("partnerEntityCode", partnerEntityCode);
contributionRow.put("translatedAmount", translatedAmount);
contributionRow.put("ownershipRatio", ownershipRatio);
contributionRow.put("finalAmount", finalAmount);
contributionRow.put("internalTrade", internal ? "Y" : "N");
payload.getContributionRows().add(contributionRow);
grossContribution = grossContribution.add(weightedAmount);
if (internal) {
eliminationAmount = eliminationAmount.add(weightedAmount.abs());
Map<String, Object> eliminationRow = new LinkedHashMap<>();
eliminationRow.put("entityCode", entityCode);
eliminationRow.put("partnerEntityCode", partnerEntityCode);
eliminationRow.put("accountCode", accountCode);
eliminationRow.put("eliminationAmount", weightedAmount.negate().setScale(2, RoundingMode.HALF_UP));
eliminationRow.put("note", "Intercompany elimination");
payload.getEliminationRows().add(eliminationRow);
}
}
for (Map<String, Object> source : forecastRows) {
BigDecimal translatedAmount = decimal(source.get("translatedAmount"));
forecastAmount = forecastAmount.add(translatedAmount);
Map<String, Object> forecastRow = new LinkedHashMap<>();
forecastRow.put("entityCode", String.valueOf(source.get("entityCode")));
forecastRow.put("accountCode", String.valueOf(source.get("accountCode")));
forecastRow.put("scenarioCode", String.valueOf(source.get("scenarioCode")));
forecastRow.put("translatedAmount", translatedAmount);
payload.getForecastRows().add(forecastRow);
}
payload.getMetrics().put("acceptedTrialRows", BigDecimal.valueOf(trialRows.size()));
payload.getMetrics().put("acceptedForecastRows", BigDecimal.valueOf(forecastRows.size()));
payload.getMetrics().put("grossContributionKrw", grossContribution.setScale(2, RoundingMode.HALF_UP));
payload.getMetrics().put("eliminationKrw", eliminationAmount.setScale(2, RoundingMode.HALF_UP));
payload.getMetrics().put("forecastKrw", forecastAmount.setScale(2, RoundingMode.HALF_UP));
payload.getMetrics().put("netContributionKrw", grossContribution.subtract(eliminationAmount).setScale(2, RoundingMode.HALF_UP));
return payload;
}
private BigDecimal decimal(Object value) {
if (value == null) {
return BigDecimal.ZERO;
}
return new BigDecimal(String.valueOf(value));
}
}

View File

@ -0,0 +1,53 @@
package com.hanwha.nexacrodemo.master;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface MasterDataMapper {
@Select("""
select entity_code, entity_name, base_currency
from legal_entity
order by entity_code
""")
List<Map<String, Object>> findEntities();
@Select("""
select account_code, account_name, account_category, internal_trade_yn
from account_code
order by account_code
""")
List<Map<String, Object>> findAccounts();
@Select("""
select fiscal_period, currency_code, rate_to_krw
from fx_rate
order by fiscal_period, currency_code
""")
List<Map<String, Object>> findFxRates();
@Select("""
select parent_entity_code, child_entity_code, ownership_ratio
from ownership_rate
order by parent_entity_code, child_entity_code
""")
List<Map<String, Object>> findOwnershipRates();
@Select("""
select template_code, template_name, template_version, description
from upload_template
order by template_code
""")
List<Map<String, Object>> findTemplates();
@Select("""
select username, full_name, role_code, active_yn
from app_user
order by username
""")
List<Map<String, Object>> findUsers();
}

View File

@ -0,0 +1,74 @@
package com.hanwha.nexacrodemo.master;
import com.hanwha.nexacrodemo.common.TxResponse;
import com.hanwha.nexacrodemo.common.MapKeyUtils;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@Service
public class MasterDataService {
private final MasterDataMapper masterDataMapper;
public MasterDataService(MasterDataMapper masterDataMapper) {
this.masterDataMapper = masterDataMapper;
}
public TxResponse loadReferenceData() {
List<Map<String, Object>> entities = MapKeyUtils.camelizeList(masterDataMapper.findEntities());
List<Map<String, Object>> accounts = MapKeyUtils.camelizeList(masterDataMapper.findAccounts());
List<Map<String, Object>> fxRates = MapKeyUtils.camelizeList(masterDataMapper.findFxRates());
List<Map<String, Object>> ownershipRates = MapKeyUtils.camelizeList(masterDataMapper.findOwnershipRates());
List<Map<String, Object>> templates = MapKeyUtils.camelizeList(masterDataMapper.findTemplates());
List<Map<String, Object>> users = MapKeyUtils.camelizeList(masterDataMapper.findUsers());
return TxResponse.ok()
.addVariable("entityCount", entities.size())
.addVariable("accountCount", accounts.size())
.addVariable("fxRateCount", fxRates.size())
.addVariable("ownershipCount", ownershipRates.size())
.addDataset("entities", entities)
.addDataset("accounts", accounts)
.addDataset("fxRates", fxRates)
.addDataset("ownerships", ownershipRates)
.addDataset("templates", templates)
.addDataset("users", users);
}
public ReferenceSnapshot snapshot() {
return new ReferenceSnapshot(
MapKeyUtils.camelizeList(masterDataMapper.findEntities()),
MapKeyUtils.camelizeList(masterDataMapper.findAccounts()),
MapKeyUtils.camelizeList(masterDataMapper.findFxRates()),
MapKeyUtils.camelizeList(masterDataMapper.findOwnershipRates()),
MapKeyUtils.camelizeList(masterDataMapper.findTemplates())
);
}
public record ReferenceSnapshot(
List<Map<String, Object>> entities,
List<Map<String, Object>> accounts,
List<Map<String, Object>> fxRates,
List<Map<String, Object>> ownershipRates,
List<Map<String, Object>> templates
) {
public Set<String> entityCodes() {
return entities.stream().map(item -> String.valueOf(item.get("entityCode"))).collect(Collectors.toSet());
}
public Set<String> accountCodes() {
return accounts.stream().map(item -> String.valueOf(item.get("accountCode"))).collect(Collectors.toSet());
}
public Set<String> internalTradeAccounts() {
return accounts.stream()
.filter(item -> "Y".equals(String.valueOf(item.get("internalTradeYn"))))
.map(item -> String.valueOf(item.get("accountCode")))
.collect(Collectors.toSet());
}
}
}

View File

@ -0,0 +1,59 @@
package com.hanwha.nexacrodemo.minio;
import com.hanwha.nexacrodemo.common.ApiException;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(name = "app.storage.provider", havingValue = "local")
public class LocalObjectStorageService implements ObjectStorageService {
private final Path rootPath;
public LocalObjectStorageService(@Value("${app.storage.local-root}") String rootPath) {
this.rootPath = Path.of(rootPath);
}
@PostConstruct
public void initialize() {
try {
Files.createDirectories(rootPath);
} catch (IOException exception) {
throw new IllegalStateException("로컬 스토리지를 초기화할 수 없습니다.", exception);
}
}
@Override
public void putObject(String objectKey, byte[] bytes, String contentType) {
try {
Path target = rootPath.resolve(objectKey);
Files.createDirectories(target.getParent());
Files.write(target, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
Files.writeString(target.resolveSibling(target.getFileName() + ".contentType"), contentType, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException exception) {
throw new IllegalStateException("로컬 스토리지 저장에 실패했습니다.", exception);
}
}
@Override
public StoredObject getObject(String objectKey) {
try {
Path target = rootPath.resolve(objectKey);
if (!Files.exists(target)) {
throw new ApiException(HttpStatus.NOT_FOUND, "산출물을 찾을 수 없습니다.");
}
String contentType = Files.readString(target.resolveSibling(target.getFileName() + ".contentType"));
return new StoredObject(Files.readAllBytes(target), contentType);
} catch (IOException exception) {
throw new IllegalStateException("로컬 스토리지 조회에 실패했습니다.", exception);
}
}
}

View File

@ -0,0 +1,85 @@
package com.hanwha.nexacrodemo.minio;
import com.hanwha.nexacrodemo.common.ApiException;
import io.minio.BucketExistsArgs;
import io.minio.GetObjectArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import jakarta.annotation.PostConstruct;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(name = "app.storage.provider", havingValue = "minio", matchIfMissing = true)
public class MinioObjectStorageService implements ObjectStorageService {
private final String bucket;
private final MinioClient minioClient;
public MinioObjectStorageService(
@Value("${app.storage.endpoint}") String endpoint,
@Value("${app.storage.access-key}") String accessKey,
@Value("${app.storage.secret-key}") String secretKey,
@Value("${app.storage.bucket}") String bucket
) {
this.bucket = bucket;
this.minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
@PostConstruct
public void initialize() {
for (int attempt = 1; attempt <= 20; attempt++) {
try {
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
return;
} catch (Exception exception) {
if (attempt == 20) {
throw new IllegalStateException("MinIO 버킷 초기화에 실패했습니다.", exception);
}
try {
Thread.sleep(2000);
} catch (InterruptedException interruptedException) {
Thread.currentThread().interrupt();
throw new IllegalStateException("MinIO 초기화 대기 중 인터럽트가 발생했습니다.", interruptedException);
}
}
}
}
@Override
public void putObject(String objectKey, byte[] bytes, String contentType) {
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(objectKey)
.contentType(contentType)
.stream(new ByteArrayInputStream(bytes), bytes.length, -1)
.build()
);
} catch (Exception exception) {
throw new IllegalStateException("MinIO 업로드에 실패했습니다.", exception);
}
}
@Override
public StoredObject getObject(String objectKey) {
try (var inputStream = minioClient.getObject(GetObjectArgs.builder().bucket(bucket).object(objectKey).build())) {
return new StoredObject(inputStream.readAllBytes(), "application/octet-stream");
} catch (IOException exception) {
throw new IllegalStateException("MinIO 파일 읽기에 실패했습니다.", exception);
} catch (Exception exception) {
throw new ApiException(HttpStatus.NOT_FOUND, "산출물을 찾을 수 없습니다.");
}
}
}

View File

@ -0,0 +1,9 @@
package com.hanwha.nexacrodemo.minio;
public interface ObjectStorageService {
void putObject(String objectKey, byte[] bytes, String contentType);
StoredObject getObject(String objectKey);
}

View File

@ -0,0 +1,21 @@
package com.hanwha.nexacrodemo.minio;
public class StoredObject {
private final byte[] bytes;
private final String contentType;
public StoredObject(byte[] bytes, String contentType) {
this.bytes = bytes;
this.contentType = contentType;
}
public byte[] getBytes() {
return bytes;
}
public String getContentType() {
return contentType;
}
}

View File

@ -0,0 +1,51 @@
package com.hanwha.nexacrodemo.report;
public class ReportArtifactCommand {
private Long id;
private Long runId;
private String artifactType;
private String objectKey;
private String downloadName;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getRunId() {
return runId;
}
public void setRunId(Long runId) {
this.runId = runId;
}
public String getArtifactType() {
return artifactType;
}
public void setArtifactType(String artifactType) {
this.artifactType = artifactType;
}
public String getObjectKey() {
return objectKey;
}
public void setObjectKey(String objectKey) {
this.objectKey = objectKey;
}
public String getDownloadName() {
return downloadName;
}
public void setDownloadName(String downloadName) {
this.downloadName = downloadName;
}
}

View File

@ -0,0 +1,29 @@
package com.hanwha.nexacrodemo.report;
import com.hanwha.nexacrodemo.auth.AuthService;
import jakarta.servlet.http.HttpSession;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/reports")
public class ReportController {
private final AuthService authService;
private final ReportService reportService;
public ReportController(AuthService authService, ReportService reportService) {
this.authService = authService;
this.reportService = reportService;
}
@GetMapping("/{artifactId}/download")
public ResponseEntity<ByteArrayResource> download(@PathVariable Long artifactId, HttpSession session) {
authService.requireRole(session, "VIEWER");
return reportService.downloadArtifact(artifactId);
}
}

View File

@ -0,0 +1,44 @@
package com.hanwha.nexacrodemo.report;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface ReportMapper {
@Insert("""
insert into report_artifact (
run_id,
artifact_type,
object_key,
download_name
) values (
#{runId},
#{artifactType},
#{objectKey},
#{downloadName}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertArtifact(ReportArtifactCommand command);
@Select("""
select id, run_id, artifact_type, object_key, download_name, created_at
from report_artifact
order by id desc
fetch first 20 rows only
""")
List<Map<String, Object>> listArtifacts();
@Select("""
select id, run_id, artifact_type, object_key, download_name, created_at
from report_artifact
where id = #{id}
""")
Map<String, Object> findArtifactById(Long id);
}

View File

@ -0,0 +1,153 @@
package com.hanwha.nexacrodemo.report;
import com.hanwha.nexacrodemo.common.ApiException;
import com.hanwha.nexacrodemo.common.MapKeyUtils;
import com.hanwha.nexacrodemo.common.TxResponse;
import com.hanwha.nexacrodemo.consolidation.ConsolidationMapper;
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.Paragraph;
import com.lowagie.text.pdf.PdfWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
private final ReportMapper reportMapper;
private final ConsolidationMapper consolidationMapper;
private final ObjectStorageService objectStorageService;
public ReportService(
ReportMapper reportMapper,
ConsolidationMapper consolidationMapper,
ObjectStorageService objectStorageService
) {
this.reportMapper = reportMapper;
this.consolidationMapper = consolidationMapper;
this.objectStorageService = objectStorageService;
}
public TxResponse loadRunOverview() {
return TxResponse.ok().addDataset("runs", MapKeyUtils.camelizeList(consolidationMapper.listRuns()));
}
public TxResponse loadReportOverview() {
return TxResponse.ok()
.addDataset("artifacts", MapKeyUtils.camelizeList(reportMapper.listArtifacts()))
.addDataset("jobLogs", MapKeyUtils.camelizeList(consolidationMapper.listJobLogs()));
}
public void generateReports(Long runId, String fiscalPeriod, ConsolidationPayload payload) {
try {
byte[] excelBytes = buildExcel(payload);
byte[] pdfBytes = buildPdf(fiscalPeriod, payload);
ReportArtifactCommand excelArtifact = new ReportArtifactCommand();
excelArtifact.setRunId(runId);
excelArtifact.setArtifactType("EXCEL");
excelArtifact.setObjectKey("runs/" + runId + "/consolidation-" + fiscalPeriod + ".xlsx");
excelArtifact.setDownloadName("consolidation-" + fiscalPeriod + ".xlsx");
objectStorageService.putObject(excelArtifact.getObjectKey(), excelBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
reportMapper.insertArtifact(excelArtifact);
ReportArtifactCommand pdfArtifact = new ReportArtifactCommand();
pdfArtifact.setRunId(runId);
pdfArtifact.setArtifactType("PDF");
pdfArtifact.setObjectKey("runs/" + runId + "/consolidation-" + fiscalPeriod + ".pdf");
pdfArtifact.setDownloadName("consolidation-" + fiscalPeriod + ".pdf");
objectStorageService.putObject(pdfArtifact.getObjectKey(), pdfBytes, "application/pdf");
reportMapper.insertArtifact(pdfArtifact);
} catch (IOException exception) {
throw new IllegalStateException("리포트 생성에 실패했습니다.", exception);
}
}
public ResponseEntity<ByteArrayResource> downloadArtifact(Long artifactId) {
Map<String, Object> artifactSource = reportMapper.findArtifactById(artifactId);
if (artifactSource == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "산출물을 찾을 수 없습니다.");
}
Map<String, Object> artifact = MapKeyUtils.camelize(artifactSource);
StoredObject storedObject = objectStorageService.getObject(String.valueOf(artifact.get("objectKey")));
String downloadName = String.valueOf(artifact.get("downloadName"));
MediaType mediaType = downloadName.endsWith(".pdf")
? MediaType.APPLICATION_PDF
: MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
return ResponseEntity.ok()
.contentType(mediaType)
.header("Content-Disposition", "attachment; filename=\"" + downloadName + "\"")
.body(new ByteArrayResource(storedObject.getBytes()));
}
private byte[] buildExcel(ConsolidationPayload payload) throws IOException {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Sheet summarySheet = workbook.createSheet("Summary");
int rowIndex = 0;
for (Map.Entry<String, BigDecimal> metric : payload.getMetrics().entrySet()) {
Row row = summarySheet.createRow(rowIndex++);
row.createCell(0).setCellValue(metric.getKey());
row.createCell(1).setCellValue(metric.getValue().doubleValue());
}
writeRows(workbook.createSheet("Contributions"), payload.getContributionRows());
writeRows(workbook.createSheet("Eliminations"), payload.getEliminationRows());
writeRows(workbook.createSheet("Forecast"), payload.getForecastRows());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private byte[] buildPdf(String fiscalPeriod, ConsolidationPayload payload) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
Document document = new Document();
PdfWriter.getInstance(document, outputStream);
document.open();
document.add(new Paragraph("Hanwha Consolidation Demo"));
document.add(new Paragraph("Fiscal period: " + fiscalPeriod));
document.add(new Paragraph(" "));
for (Map.Entry<String, BigDecimal> metric : payload.getMetrics().entrySet()) {
document.add(new Paragraph(metric.getKey() + ": " + metric.getValue()));
}
document.close();
return outputStream.toByteArray();
}
private void writeRows(Sheet sheet, List<Map<String, Object>> rows) {
if (rows.isEmpty()) {
Row row = sheet.createRow(0);
row.createCell(0).setCellValue("No data");
return;
}
Row header = sheet.createRow(0);
int cellIndex = 0;
for (String key : rows.get(0).keySet()) {
header.createCell(cellIndex++).setCellValue(key);
}
int rowIndex = 1;
for (Map<String, Object> item : rows) {
Row row = sheet.createRow(rowIndex++);
int valueIndex = 0;
for (Object value : item.values()) {
row.createCell(valueIndex++).setCellValue(value == null ? "" : String.valueOf(value));
}
}
}
}

View File

@ -0,0 +1,57 @@
package com.hanwha.nexacrodemo.tx;
import com.hanwha.nexacrodemo.auth.AuthService;
import com.hanwha.nexacrodemo.common.TxResponse;
import com.hanwha.nexacrodemo.master.MasterDataService;
import com.hanwha.nexacrodemo.report.ReportService;
import com.hanwha.nexacrodemo.upload.UploadService;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/tx")
public class TransactionController {
private final AuthService authService;
private final MasterDataService masterDataService;
private final UploadService uploadService;
private final ReportService reportService;
public TransactionController(
AuthService authService,
MasterDataService masterDataService,
UploadService uploadService,
ReportService reportService
) {
this.authService = authService;
this.masterDataService = masterDataService;
this.uploadService = uploadService;
this.reportService = reportService;
}
@GetMapping("/master/reference")
public TxResponse masterReference(HttpSession session) {
authService.requireRole(session, "VIEWER");
return masterDataService.loadReferenceData();
}
@GetMapping("/uploads/overview")
public TxResponse uploadOverview(HttpSession session) {
authService.requireRole(session, "OPERATOR");
return uploadService.loadOverview();
}
@GetMapping("/consolidations/overview")
public TxResponse consolidationOverview(HttpSession session) {
authService.requireRole(session, "VIEWER");
return reportService.loadRunOverview();
}
@GetMapping("/reports/overview")
public TxResponse reportsOverview(HttpSession session) {
authService.requireRole(session, "VIEWER");
return reportService.loadReportOverview();
}
}

View File

@ -0,0 +1,107 @@
package com.hanwha.nexacrodemo.upload;
import java.math.BigDecimal;
public class ParsedWorkbookRow {
private Integer rowNumber;
private String fiscalPeriod;
private String entityCode;
private String accountCode;
private String partnerEntityCode;
private String currencyCode;
private BigDecimal debitAmount;
private BigDecimal creditAmount;
private BigDecimal amountValue;
private String scenarioCode;
private BigDecimal translatedAmount;
public Integer getRowNumber() {
return rowNumber;
}
public void setRowNumber(Integer rowNumber) {
this.rowNumber = rowNumber;
}
public String getFiscalPeriod() {
return fiscalPeriod;
}
public void setFiscalPeriod(String fiscalPeriod) {
this.fiscalPeriod = fiscalPeriod;
}
public String getEntityCode() {
return entityCode;
}
public void setEntityCode(String entityCode) {
this.entityCode = entityCode;
}
public String getAccountCode() {
return accountCode;
}
public void setAccountCode(String accountCode) {
this.accountCode = accountCode;
}
public String getPartnerEntityCode() {
return partnerEntityCode;
}
public void setPartnerEntityCode(String partnerEntityCode) {
this.partnerEntityCode = partnerEntityCode;
}
public String getCurrencyCode() {
return currencyCode;
}
public void setCurrencyCode(String currencyCode) {
this.currencyCode = currencyCode;
}
public BigDecimal getDebitAmount() {
return debitAmount;
}
public void setDebitAmount(BigDecimal debitAmount) {
this.debitAmount = debitAmount;
}
public BigDecimal getCreditAmount() {
return creditAmount;
}
public void setCreditAmount(BigDecimal creditAmount) {
this.creditAmount = creditAmount;
}
public BigDecimal getAmountValue() {
return amountValue;
}
public void setAmountValue(BigDecimal amountValue) {
this.amountValue = amountValue;
}
public String getScenarioCode() {
return scenarioCode;
}
public void setScenarioCode(String scenarioCode) {
this.scenarioCode = scenarioCode;
}
public BigDecimal getTranslatedAmount() {
return translatedAmount;
}
public void setTranslatedAmount(BigDecimal translatedAmount) {
this.translatedAmount = translatedAmount;
}
}

View File

@ -0,0 +1,96 @@
package com.hanwha.nexacrodemo.upload;
public class UploadBatchCommand {
private Long id;
private String templateCode;
private String templateVersion;
private String fiscalPeriod;
private String originalFilename;
private String fileChecksum;
private String statusCode;
private String uploadedBy;
private Integer rowCount;
private Integer errorCount;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTemplateCode() {
return templateCode;
}
public void setTemplateCode(String templateCode) {
this.templateCode = templateCode;
}
public String getTemplateVersion() {
return templateVersion;
}
public void setTemplateVersion(String templateVersion) {
this.templateVersion = templateVersion;
}
public String getFiscalPeriod() {
return fiscalPeriod;
}
public void setFiscalPeriod(String fiscalPeriod) {
this.fiscalPeriod = fiscalPeriod;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getFileChecksum() {
return fileChecksum;
}
public void setFileChecksum(String fileChecksum) {
this.fileChecksum = fileChecksum;
}
public String getStatusCode() {
return statusCode;
}
public void setStatusCode(String statusCode) {
this.statusCode = statusCode;
}
public String getUploadedBy() {
return uploadedBy;
}
public void setUploadedBy(String uploadedBy) {
this.uploadedBy = uploadedBy;
}
public Integer getRowCount() {
return rowCount;
}
public void setRowCount(Integer rowCount) {
this.rowCount = rowCount;
}
public Integer getErrorCount() {
return errorCount;
}
public void setErrorCount(Integer errorCount) {
this.errorCount = errorCount;
}
}

View File

@ -0,0 +1,59 @@
package com.hanwha.nexacrodemo.upload;
import com.hanwha.nexacrodemo.auth.AuthService;
import com.hanwha.nexacrodemo.auth.SessionUser;
import jakarta.servlet.http.HttpSession;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.ByteArrayResource;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/uploads")
public class UploadController {
private final AuthService authService;
private final UploadService uploadService;
public UploadController(AuthService authService, UploadService uploadService) {
this.authService = authService;
this.uploadService = uploadService;
}
@PostMapping
public Map<String, Object> upload(
@RequestParam String templateCode,
@RequestParam String fiscalPeriod,
@RequestParam MultipartFile file,
HttpSession session
) {
SessionUser sessionUser = authService.requireRole(session, "OPERATOR");
return uploadService.handleUpload(templateCode, fiscalPeriod, file, sessionUser);
}
@GetMapping("/{batchId}/issues")
public List<Map<String, Object>> issues(@PathVariable Long batchId, HttpSession session) {
authService.requireRole(session, "VIEWER");
return uploadService.loadIssues(batchId);
}
@GetMapping("/templates/{templateCode}/download")
public ResponseEntity<ByteArrayResource> templateDownload(@PathVariable String templateCode, HttpSession session) {
authService.requireRole(session, "VIEWER");
ByteArrayResource resource = uploadService.downloadTemplate(templateCode);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + templateCode + "-template.xlsx\"")
.body(resource);
}
}

View File

@ -0,0 +1,158 @@
package com.hanwha.nexacrodemo.upload;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface UploadMapper {
@Insert("""
insert into upload_batch (
template_code,
template_version,
fiscal_period,
original_filename,
file_checksum,
status_code,
uploaded_by,
row_count,
error_count
) values (
#{templateCode},
#{templateVersion},
#{fiscalPeriod},
#{originalFilename},
#{fileChecksum},
#{statusCode},
#{uploadedBy},
#{rowCount},
#{errorCount}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id")
void insertUploadBatch(UploadBatchCommand command);
@Update("""
update upload_batch
set template_version = #{templateVersion},
status_code = #{statusCode},
row_count = #{rowCount},
error_count = #{errorCount}
where id = #{id}
""")
void finalizeUploadBatch(
@Param("id") Long id,
@Param("templateVersion") String templateVersion,
@Param("statusCode") String statusCode,
@Param("rowCount") Integer rowCount,
@Param("errorCount") Integer errorCount
);
@Insert("""
insert into upload_row (
batch_id,
row_number,
fiscal_period,
entity_code,
account_code,
partner_entity_code,
currency_code,
debit_amount,
credit_amount,
amount_value,
scenario_code,
translated_amount
) values (
#{batchId},
#{row.rowNumber},
#{row.fiscalPeriod},
#{row.entityCode},
#{row.accountCode},
#{row.partnerEntityCode},
#{row.currencyCode},
#{row.debitAmount},
#{row.creditAmount},
#{row.amountValue},
#{row.scenarioCode},
#{row.translatedAmount}
)
""")
void insertUploadRow(@Param("batchId") Long batchId, @Param("row") ParsedWorkbookRow row);
@Insert("""
insert into validation_issue (
batch_id,
row_number,
severity_code,
issue_code,
issue_message
) values (
#{batchId},
#{rowNumber},
#{severityCode},
#{issueCode},
#{issueMessage}
)
""")
void insertValidationIssue(ValidationIssueCommand command);
@Select("""
select count(*)
from upload_batch
where template_code = #{templateCode}
and fiscal_period = #{fiscalPeriod}
and status_code = 'ACCEPTED'
""")
int countAcceptedBatch(@Param("templateCode") String templateCode, @Param("fiscalPeriod") String fiscalPeriod);
@Select("""
select id, template_code, template_version, fiscal_period, original_filename, status_code, row_count, error_count, uploaded_by, uploaded_at
from upload_batch
order by id desc
fetch first 20 rows only
""")
List<Map<String, Object>> listUploadBatches();
@Select("""
select vi.batch_id, vi.row_number, vi.issue_code, vi.issue_message, vi.severity_code, vi.created_at
from validation_issue vi
join upload_batch ub on ub.id = vi.batch_id
order by vi.id desc
fetch first 50 rows only
""")
List<Map<String, Object>> listRecentValidationIssues();
@Select("""
select batch_id, row_number, issue_code, issue_message, severity_code, created_at
from validation_issue
where batch_id = #{batchId}
order by id
""")
List<Map<String, Object>> listValidationIssuesByBatchId(Long batchId);
@Select("""
select id, template_code, template_version, fiscal_period, original_filename, status_code, row_count, error_count, uploaded_by, uploaded_at
from upload_batch
where id = #{id}
""")
Map<String, Object> findBatchById(Long id);
@Select("""
select ur.row_number, ur.fiscal_period, ur.entity_code, ur.account_code, ur.partner_entity_code, ur.currency_code,
ur.debit_amount, ur.credit_amount, ur.amount_value, ur.scenario_code, ur.translated_amount
from upload_row ur
join upload_batch ub on ub.id = ur.batch_id
where ub.status_code = 'ACCEPTED'
and ub.template_code = #{templateCode}
and ub.fiscal_period = #{fiscalPeriod}
order by ur.id
""")
List<Map<String, Object>> listAcceptedRows(@Param("templateCode") String templateCode, @Param("fiscalPeriod") String fiscalPeriod);
}

View File

@ -0,0 +1,291 @@
package com.hanwha.nexacrodemo.upload;
import com.hanwha.nexacrodemo.auth.SessionUser;
import com.hanwha.nexacrodemo.common.ApiException;
import com.hanwha.nexacrodemo.common.HashUtils;
import com.hanwha.nexacrodemo.common.MapKeyUtils;
import com.hanwha.nexacrodemo.common.TxResponse;
import com.hanwha.nexacrodemo.master.MasterDataService;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
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.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
public class UploadService {
private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-\\d{2}$");
private final UploadMapper uploadMapper;
private final MasterDataService masterDataService;
private final WorkbookTemplateService workbookTemplateService;
public UploadService(
UploadMapper uploadMapper,
MasterDataService masterDataService,
WorkbookTemplateService workbookTemplateService
) {
this.uploadMapper = uploadMapper;
this.masterDataService = masterDataService;
this.workbookTemplateService = workbookTemplateService;
}
public TxResponse loadOverview() {
return TxResponse.ok()
.addDataset("uploadBatches", MapKeyUtils.camelizeList(uploadMapper.listUploadBatches()))
.addDataset("validationIssues", MapKeyUtils.camelizeList(uploadMapper.listRecentValidationIssues()));
}
public Map<String, Object> handleUpload(String templateCode, String fiscalPeriod, MultipartFile file, SessionUser sessionUser) {
if (file == null || file.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "업로드 파일이 비어 있습니다.");
}
String checksum;
try (InputStream checksumStream = file.getInputStream()) {
checksum = HashUtils.sha256(checksumStream);
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "업로드 파일을 읽을 수 없습니다.");
}
UploadBatchCommand batch = new UploadBatchCommand();
batch.setTemplateCode(templateCode);
batch.setTemplateVersion("UNKNOWN");
batch.setFiscalPeriod(fiscalPeriod);
batch.setOriginalFilename(Optional.ofNullable(file.getOriginalFilename()).orElse("upload.xlsx"));
batch.setFileChecksum(checksum);
batch.setStatusCode("PROCESSING");
batch.setUploadedBy(sessionUser.getUsername());
batch.setRowCount(0);
batch.setErrorCount(0);
uploadMapper.insertUploadBatch(batch);
List<ValidationIssueCommand> issues = new ArrayList<>();
List<ParsedWorkbookRow> parsedRows = new ArrayList<>();
String detectedVersion = "UNKNOWN";
try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) {
Map<String, String> meta = readMeta(workbook.getSheet("META"));
detectedVersion = meta.getOrDefault("templateVersion", "UNKNOWN");
String detectedTemplateCode = meta.getOrDefault("templateCode", "");
MasterDataService.ReferenceSnapshot snapshot = masterDataService.snapshot();
Set<String> entityCodes = snapshot.entityCodes();
Set<String> accountCodes = snapshot.accountCodes();
Set<String> internalAccounts = snapshot.internalTradeAccounts();
Map<String, BigDecimal> fxRates = buildFxRateMap(snapshot.fxRates());
if (!PERIOD_PATTERN.matcher(fiscalPeriod).matches()) {
issues.add(issue(batch.getId(), 0, "ERROR", "INVALID_PERIOD", "회계기간 형식은 YYYY-MM 이어야 합니다."));
}
if (!detectedTemplateCode.equals(templateCode)) {
issues.add(issue(batch.getId(), 0, "ERROR", "TEMPLATE_CODE_MISMATCH", "META 시트의 templateCode와 요청값이 일치하지 않습니다."));
}
if (!"1.0".equals(detectedVersion)) {
issues.add(issue(batch.getId(), 0, "ERROR", "UNSUPPORTED_TEMPLATE_VERSION", "지원하지 않는 템플릿 버전입니다."));
}
if (uploadMapper.countAcceptedBatch(templateCode, fiscalPeriod) > 0) {
issues.add(issue(batch.getId(), 0, "ERROR", "DUPLICATE_ACCEPTED_BATCH", "동일 템플릿과 회계기간에 대한 ACCEPTED 업로드가 이미 존재합니다."));
}
Sheet dataSheet = workbook.getSheet("DATA");
if (dataSheet == null) {
issues.add(issue(batch.getId(), 0, "ERROR", "DATA_SHEET_MISSING", "DATA 시트를 찾을 수 없습니다."));
} else {
validateHeaders(templateCode, dataSheet.getRow(0), batch.getId(), issues);
parseDataRows(templateCode, fiscalPeriod, dataSheet, batch.getId(), parsedRows, issues, entityCodes, accountCodes, internalAccounts, fxRates);
validateBatchBalance(templateCode, parsedRows, batch.getId(), issues);
}
} catch (Exception exception) {
issues.add(issue(batch.getId(), 0, "ERROR", "UNREADABLE_WORKBOOK", "Excel 파일을 해석할 수 없습니다: " + exception.getMessage()));
}
for (ParsedWorkbookRow row : parsedRows) {
uploadMapper.insertUploadRow(batch.getId(), row);
}
for (ValidationIssueCommand issue : issues) {
uploadMapper.insertValidationIssue(issue);
}
String finalStatus = issues.isEmpty() ? "ACCEPTED" : "REJECTED";
uploadMapper.finalizeUploadBatch(batch.getId(), detectedVersion, finalStatus, parsedRows.size(), issues.size());
return MapKeyUtils.camelize(uploadMapper.findBatchById(batch.getId()));
}
public List<Map<String, Object>> loadIssues(Long batchId) {
return MapKeyUtils.camelizeList(uploadMapper.listValidationIssuesByBatchId(batchId));
}
public ByteArrayResource downloadTemplate(String templateCode) {
return new ByteArrayResource(workbookTemplateService.createTemplate(templateCode));
}
private void parseDataRows(
String templateCode,
String fiscalPeriod,
Sheet dataSheet,
Long batchId,
List<ParsedWorkbookRow> parsedRows,
List<ValidationIssueCommand> issues,
Set<String> entityCodes,
Set<String> accountCodes,
Set<String> internalAccounts,
Map<String, BigDecimal> fxRates
) {
DataFormatter formatter = new DataFormatter();
for (int rowIndex = 1; rowIndex <= dataSheet.getLastRowNum(); rowIndex++) {
Row excelRow = dataSheet.getRow(rowIndex);
if (excelRow == null) {
continue;
}
ParsedWorkbookRow row = new ParsedWorkbookRow();
row.setRowNumber(rowIndex + 1);
row.setFiscalPeriod(value(formatter, excelRow, 0));
row.setEntityCode(value(formatter, excelRow, 1));
row.setAccountCode(value(formatter, excelRow, 2));
int currencyIndex = "forecast".equals(templateCode) ? 3 : 4;
row.setCurrencyCode(value(formatter, excelRow, currencyIndex));
if ("trial-balance".equals(templateCode)) {
row.setPartnerEntityCode(value(formatter, excelRow, 3));
row.setDebitAmount(decimal(value(formatter, excelRow, 5)));
row.setCreditAmount(decimal(value(formatter, excelRow, 6)));
row.setAmountValue(row.getDebitAmount().subtract(row.getCreditAmount()));
} else {
row.setScenarioCode(value(formatter, excelRow, 4));
row.setAmountValue(decimal(value(formatter, excelRow, 5)));
row.setDebitAmount(BigDecimal.ZERO);
row.setCreditAmount(BigDecimal.ZERO);
}
if (!fiscalPeriod.equals(row.getFiscalPeriod())) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "FISCAL_PERIOD_MISMATCH", "행의 회계기간이 요청값과 일치하지 않습니다."));
}
if (!entityCodes.contains(row.getEntityCode())) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "ENTITY_NOT_FOUND", "존재하지 않는 법인코드입니다: " + row.getEntityCode()));
}
if (!accountCodes.contains(row.getAccountCode())) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "ACCOUNT_NOT_FOUND", "존재하지 않는 계정코드입니다: " + row.getAccountCode()));
}
if (internalAccounts.contains(row.getAccountCode()) && isBlank(row.getPartnerEntityCode())) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "PARTNER_REQUIRED", "내부거래 계정은 상대법인코드가 필요합니다."));
}
if (!isBlank(row.getPartnerEntityCode()) && !entityCodes.contains(row.getPartnerEntityCode())) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "PARTNER_NOT_FOUND", "상대법인코드를 찾을 수 없습니다: " + row.getPartnerEntityCode()));
}
BigDecimal fxRate = fxRates.get(row.getFiscalPeriod() + "|" + row.getCurrencyCode());
if (fxRate == null) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "FX_RATE_MISSING", "환율이 존재하지 않습니다: " + row.getFiscalPeriod() + "/" + row.getCurrencyCode()));
row.setTranslatedAmount(BigDecimal.ZERO);
} else {
row.setTranslatedAmount(row.getAmountValue().multiply(fxRate).setScale(2, RoundingMode.HALF_UP));
}
if ("forecast".equals(templateCode) && isBlank(row.getScenarioCode())) {
issues.add(issue(batchId, row.getRowNumber(), "ERROR", "SCENARIO_REQUIRED", "forecast 업로드에는 scenarioCode가 필요합니다."));
}
parsedRows.add(row);
}
}
private void validateHeaders(String templateCode, Row headerRow, Long batchId, List<ValidationIssueCommand> issues) {
if (headerRow == null) {
issues.add(issue(batchId, 0, "ERROR", "HEADER_MISSING", "DATA 시트에 헤더가 없습니다."));
return;
}
DataFormatter formatter = new DataFormatter();
List<String> expectedHeaders = workbookTemplateService.expectedHeaders(templateCode);
for (int index = 0; index < expectedHeaders.size(); index++) {
String expected = expectedHeaders.get(index);
String actual = formatter.formatCellValue(headerRow.getCell(index));
if (!expected.equals(actual)) {
issues.add(issue(batchId, 0, "ERROR", "HEADER_MISMATCH", "헤더 불일치: expected=" + expected + ", actual=" + actual));
}
}
}
private void validateBatchBalance(String templateCode, List<ParsedWorkbookRow> rows, Long batchId, List<ValidationIssueCommand> issues) {
if (!"trial-balance".equals(templateCode)) {
return;
}
BigDecimal totalDebit = rows.stream().map(ParsedWorkbookRow::getDebitAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalCredit = rows.stream().map(ParsedWorkbookRow::getCreditAmount).reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalDebit.compareTo(totalCredit) != 0) {
issues.add(issue(batchId, 0, "ERROR", "TB_IMBALANCE", "차변/대변 합계가 일치하지 않습니다."));
}
}
private Map<String, String> readMeta(Sheet metaSheet) {
if (metaSheet == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "META 시트가 없습니다.");
}
DataFormatter formatter = new DataFormatter();
Map<String, String> meta = new LinkedHashMap<>();
for (int rowIndex = 1; rowIndex <= metaSheet.getLastRowNum(); rowIndex++) {
Row row = metaSheet.getRow(rowIndex);
if (row == null) {
continue;
}
String key = value(formatter, row, 0);
String value = value(formatter, row, 1);
if (!isBlank(key)) {
meta.put(key, value);
}
}
return meta;
}
private Map<String, BigDecimal> buildFxRateMap(List<Map<String, Object>> fxRates) {
Map<String, BigDecimal> rates = new LinkedHashMap<>();
for (Map<String, Object> row : fxRates) {
String key = row.get("fiscalPeriod") + "|" + row.get("currencyCode");
rates.put(key, new BigDecimal(String.valueOf(row.get("rateToKrw"))));
}
return rates;
}
private ValidationIssueCommand issue(Long batchId, int rowNumber, String severityCode, String issueCode, String issueMessage) {
ValidationIssueCommand command = new ValidationIssueCommand();
command.setBatchId(batchId);
command.setRowNumber(rowNumber);
command.setSeverityCode(severityCode);
command.setIssueCode(issueCode);
command.setIssueMessage(issueMessage);
return command;
}
private String value(DataFormatter formatter, Row row, int cellIndex) {
Cell cell = row.getCell(cellIndex);
return cell == null ? "" : formatter.formatCellValue(cell).trim();
}
private BigDecimal decimal(String rawValue) {
if (isBlank(rawValue)) {
return BigDecimal.ZERO;
}
return new BigDecimal(rawValue.trim());
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@ -0,0 +1,51 @@
package com.hanwha.nexacrodemo.upload;
public class ValidationIssueCommand {
private Long batchId;
private Integer rowNumber;
private String severityCode;
private String issueCode;
private String issueMessage;
public Long getBatchId() {
return batchId;
}
public void setBatchId(Long batchId) {
this.batchId = batchId;
}
public Integer getRowNumber() {
return rowNumber;
}
public void setRowNumber(Integer rowNumber) {
this.rowNumber = rowNumber;
}
public String getSeverityCode() {
return severityCode;
}
public void setSeverityCode(String severityCode) {
this.severityCode = severityCode;
}
public String getIssueCode() {
return issueCode;
}
public void setIssueCode(String issueCode) {
this.issueCode = issueCode;
}
public String getIssueMessage() {
return issueMessage;
}
public void setIssueMessage(String issueMessage) {
this.issueMessage = issueMessage;
}
}

View File

@ -0,0 +1,58 @@
package com.hanwha.nexacrodemo.upload;
import com.hanwha.nexacrodemo.common.ApiException;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
@Service
public class WorkbookTemplateService {
private static final Map<String, List<String>> HEADERS = Map.of(
"trial-balance", List.of("fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount"),
"forecast", List.of("fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue")
);
public List<String> expectedHeaders(String templateCode) {
List<String> headers = HEADERS.get(templateCode);
if (headers == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "지원하지 않는 템플릿입니다: " + templateCode);
}
return headers;
}
public byte[] createTemplate(String templateCode) {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
XSSFSheet metaSheet = workbook.createSheet("META");
Row metaHeader = metaSheet.createRow(0);
metaHeader.createCell(0).setCellValue("key");
metaHeader.createCell(1).setCellValue("value");
Row templateRow = metaSheet.createRow(1);
templateRow.createCell(0).setCellValue("templateCode");
templateRow.createCell(1).setCellValue(templateCode);
Row versionRow = metaSheet.createRow(2);
versionRow.createCell(0).setCellValue("templateVersion");
versionRow.createCell(1).setCellValue("1.0");
XSSFSheet dataSheet = workbook.createSheet("DATA");
Row header = dataSheet.createRow(0);
List<String> headers = expectedHeaders(templateCode);
for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(index));
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("템플릿 생성에 실패했습니다.", exception);
}
}
}

View File

@ -0,0 +1,4 @@
app:
batch:
enabled: true

View File

@ -0,0 +1,60 @@
spring:
application:
name: hanwha-nexacro-demo
datasource:
driver-class-name: org.postgresql.Driver
url: ${DB_URL:jdbc:postgresql://localhost:5432/hanwha_demo}
username: ${DB_USERNAME:hanwha}
password: ${DB_PASSWORD:hanwha1234}
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
jackson:
default-property-inclusion: non_null
time-zone: Asia/Seoul
flyway:
enabled: true
session:
timeout: 30m
server:
servlet:
session:
cookie:
name: HNEX_DEMO_SESSION
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
mybatis:
configuration:
map-underscore-to-camel-case: true
app:
batch:
enabled: false
poll-interval-ms: ${BATCH_POLL_INTERVAL_MS:5000}
worker-name: ${BATCH_WORKER_NAME:batch-1}
storage:
provider: ${STORAGE_PROVIDER:minio}
bucket: ${MINIO_BUCKET:reports}
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
access-key: ${MINIO_ACCESS_KEY:minioadmin}
secret-key: ${MINIO_SECRET_KEY:minioadmin}
local-root: ${LOCAL_STORAGE_ROOT:./build/storage}
sample-data:
root: ${SAMPLE_DATA_ROOT:/workspace/sample-data}
logging:
level:
root: INFO
com.hanwha.nexacrodemo: INFO

View File

@ -0,0 +1,133 @@
create table app_user (
id bigint generated by default as identity primary key,
username varchar(50) not null unique,
password_text varchar(100) not null,
full_name varchar(100) not null,
role_code varchar(20) not null,
active_yn varchar(1) not null default 'Y'
);
create table legal_entity (
id bigint generated by default as identity primary key,
entity_code varchar(20) not null unique,
entity_name varchar(100) not null,
base_currency varchar(10) not null
);
create table account_code (
id bigint generated by default as identity primary key,
account_code varchar(30) not null unique,
account_name varchar(150) not null,
account_category varchar(40) not null,
internal_trade_yn varchar(1) not null default 'N'
);
create table fx_rate (
id bigint generated by default as identity primary key,
fiscal_period varchar(7) not null,
currency_code varchar(10) not null,
rate_to_krw decimal(18, 6) not null,
constraint uk_fx_rate unique (fiscal_period, currency_code)
);
create table ownership_rate (
id bigint generated by default as identity primary key,
parent_entity_code varchar(20) not null,
child_entity_code varchar(20) not null,
ownership_ratio decimal(10, 4) not null,
constraint uk_ownership_rate unique (parent_entity_code, child_entity_code)
);
create table upload_template (
id bigint generated by default as identity primary key,
template_code varchar(50) not null unique,
template_name varchar(150) not null,
template_version varchar(20) not null,
description varchar(500)
);
create table upload_batch (
id bigint generated by default as identity primary key,
template_code varchar(50) not null,
template_version varchar(20) not null,
fiscal_period varchar(7) not null,
original_filename varchar(255) not null,
file_checksum varchar(128) not null,
status_code varchar(20) not null,
uploaded_by varchar(50) not null,
uploaded_at timestamp not null default current_timestamp,
row_count integer not null default 0,
error_count integer not null default 0
);
create table upload_row (
id bigint generated by default as identity primary key,
batch_id bigint not null,
row_number integer not null,
fiscal_period varchar(7) not null,
entity_code varchar(20) not null,
account_code varchar(30) not null,
partner_entity_code varchar(20),
currency_code varchar(10) not null,
debit_amount decimal(18, 2),
credit_amount decimal(18, 2),
amount_value decimal(18, 2),
scenario_code varchar(30),
translated_amount decimal(18, 2),
constraint fk_upload_row_batch foreign key (batch_id) references upload_batch (id)
);
create table validation_issue (
id bigint generated by default as identity primary key,
batch_id bigint not null,
row_number integer not null,
severity_code varchar(20) not null,
issue_code varchar(50) not null,
issue_message varchar(500) not null,
created_at timestamp not null default current_timestamp,
constraint fk_validation_issue_batch foreign key (batch_id) references upload_batch (id)
);
create table consolidation_run (
id bigint generated by default as identity primary key,
fiscal_period varchar(7) not null,
status_code varchar(20) not null,
requested_by varchar(50) not null,
requested_at timestamp not null default current_timestamp,
started_at timestamp,
finished_at timestamp,
report_currency varchar(10) not null,
summary_message varchar(500),
worker_name varchar(100)
);
create table elimination_entry (
id bigint generated by default as identity primary key,
run_id bigint not null,
entity_code varchar(20) not null,
partner_entity_code varchar(20),
account_code varchar(30) not null,
elimination_amount decimal(18, 2) not null,
note varchar(255),
constraint fk_elimination_run foreign key (run_id) references consolidation_run (id)
);
create table report_artifact (
id bigint generated by default as identity primary key,
run_id bigint not null,
artifact_type varchar(20) not null,
object_key varchar(255) not null,
download_name varchar(255) not null,
created_at timestamp not null default current_timestamp,
constraint fk_report_artifact_run foreign key (run_id) references consolidation_run (id)
);
create table job_log (
id bigint generated by default as identity primary key,
job_type varchar(50) not null,
reference_id bigint,
log_level varchar(20) not null,
log_message varchar(500) not null,
created_at timestamp not null default current_timestamp
);

View File

@ -0,0 +1,33 @@
insert into app_user (username, password_text, full_name, role_code, active_yn) values
('admin', 'demo1234', 'Demo Admin', 'ADMIN', 'Y'),
('operator', 'demo1234', 'Demo Operator', 'OPERATOR', 'Y'),
('viewer', 'demo1234', 'Demo Viewer', 'VIEWER', 'Y');
insert into legal_entity (entity_code, entity_name, base_currency) values
('HQ', 'Hanwha HQ', 'KRW'),
('US1', 'Hanwha USA', 'USD'),
('SG1', 'Hanwha Singapore', 'SGD');
insert into account_code (account_code, account_name, account_category, internal_trade_yn) values
('CASH', 'Cash', 'ASSET', 'N'),
('AR_EXT', 'External Receivable', 'ASSET', 'N'),
('AR_INT', 'Intercompany Receivable', 'ASSET', 'Y'),
('AP_INT', 'Intercompany Payable', 'LIABILITY', 'Y'),
('REV_EXT', 'External Revenue', 'REVENUE', 'N'),
('EXP_OPEX', 'Operating Expense', 'EXPENSE', 'N');
insert into fx_rate (fiscal_period, currency_code, rate_to_krw) values
('2026-03', 'KRW', 1.000000),
('2026-03', 'USD', 1350.000000),
('2026-03', 'SGD', 990.000000),
('2026-04', 'KRW', 1.000000),
('2026-04', 'USD', 1362.000000),
('2026-04', 'SGD', 1002.000000);
insert into ownership_rate (parent_entity_code, child_entity_code, ownership_ratio) values
('HQ', 'US1', 1.0000),
('HQ', 'SG1', 0.8000);
insert into upload_template (template_code, template_name, template_version, description) values
('trial-balance', 'Trial Balance Upload', '1.0', '법인별 trial balance 업로드'),
('forecast', 'Forecast Upload', '1.0', '전망 데이터 업로드');

View File

@ -0,0 +1,94 @@
package com.hanwha.nexacrodemo.consolidation;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.hanwha.nexacrodemo.upload.TestWorkbookFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
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.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ConsolidationIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ConsolidationService consolidationService;
@Test
void validUploadsCanBeConsolidatedAndReported() throws Exception {
MockHttpSession session = login("operator", "demo1234");
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": "2026-03",
"reportCurrency": "KRW"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("REQUESTED"))
.andReturn();
consolidationService.processPendingRuns("test-worker");
mockMvc.perform(get("/api/tx/consolidations/overview").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.datasets.runs[0].statusCode").value("SUCCESS"));
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());
}
private void upload(MockHttpSession session, String templateCode, String fileName, byte[] content) throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
fileName,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
content
);
mockMvc.perform(multipart("/api/uploads")
.file(file)
.param("templateCode", templateCode)
.param("fiscalPeriod", "2026-03")
.session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("ACCEPTED"));
}
private MockHttpSession login(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "%s",
"password": "%s"
}
""".formatted(username, password)))
.andExpect(status().isOk())
.andReturn();
return (MockHttpSession) result.getRequest().getSession(false);
}
}

View File

@ -0,0 +1,52 @@
package com.hanwha.nexacrodemo.tx;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class TransactionControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void masterReferenceReturnsDatasetsForLoggedInUser() throws Exception {
MockHttpSession session = login("viewer", "demo1234");
mockMvc.perform(get("/api/tx/master/reference").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.variables.entityCount").value(3))
.andExpect(jsonPath("$.datasets.entities.length()").value(3))
.andExpect(jsonPath("$.datasets.accounts.length()").value(6));
}
private MockHttpSession login(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "%s",
"password": "%s"
}
""".formatted(username, password)))
.andExpect(status().isOk())
.andReturn();
return (MockHttpSession) result.getRequest().getSession(false);
}
}

View File

@ -0,0 +1,74 @@
package com.hanwha.nexacrodemo.upload;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
public final class TestWorkbookFactory {
private TestWorkbookFactory() {
}
public static byte[] trialBalanceInvalid() {
return workbook("trial-balance", List.of(
List.of("2026-03", "US1", "AR_INT", "", "USD", "100", "0"),
List.of("2026-03", "HQ", "REV_EXT", "", "KRW", "0", "70")
));
}
public static byte[] trialBalanceValid() {
return workbook("trial-balance", List.of(
List.of("2026-03", "US1", "AR_INT", "HQ", "USD", "100", "0"),
List.of("2026-03", "HQ", "AP_INT", "US1", "KRW", "0", "100"),
List.of("2026-03", "HQ", "REV_EXT", "", "KRW", "0", "200000"),
List.of("2026-03", "HQ", "EXP_OPEX", "", "KRW", "200000", "0")
));
}
public static byte[] forecastValid() {
return workbook("forecast", List.of(
List.of("2026-03", "US1", "REV_EXT", "USD", "PLAN", "500"),
List.of("2026-03", "SG1", "EXP_OPEX", "SGD", "FORECAST", "100")
));
}
private static byte[] workbook(String templateCode, List<List<String>> rows) {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
XSSFSheet metaSheet = workbook.createSheet("META");
Row metaHeader = metaSheet.createRow(0);
metaHeader.createCell(0).setCellValue("key");
metaHeader.createCell(1).setCellValue("value");
Row templateRow = metaSheet.createRow(1);
templateRow.createCell(0).setCellValue("templateCode");
templateRow.createCell(1).setCellValue(templateCode);
Row versionRow = metaSheet.createRow(2);
versionRow.createCell(0).setCellValue("templateVersion");
versionRow.createCell(1).setCellValue("1.0");
XSSFSheet dataSheet = workbook.createSheet("DATA");
List<String> headers = "trial-balance".equals(templateCode)
? List.of("fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount")
: List.of("fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue");
Row header = dataSheet.createRow(0);
for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(index));
}
int rowIndex = 1;
for (List<String> rowValues : rows) {
Row row = dataSheet.createRow(rowIndex++);
for (int cellIndex = 0; cellIndex < rowValues.size(); cellIndex++) {
row.createCell(cellIndex).setCellValue(rowValues.get(cellIndex));
}
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException(exception);
}
}
}

View File

@ -0,0 +1,68 @@
package com.hanwha.nexacrodemo.upload;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.mock.web.MockMultipartFile;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UploadValidationIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void invalidTrialBalanceProducesRejectedBatchAndIssues() throws Exception {
MockHttpSession session = login("operator", "demo1234");
MockMultipartFile file = new MockMultipartFile(
"file",
"trial-balance-invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
TestWorkbookFactory.trialBalanceInvalid()
);
mockMvc.perform(multipart("/api/uploads")
.file(file)
.param("templateCode", "trial-balance")
.param("fiscalPeriod", "2026-03")
.session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("REJECTED"))
.andExpect(jsonPath("$.errorCount").isNumber())
.andExpect(jsonPath("$.rowCount").value(2));
mockMvc.perform(get("/api/tx/uploads/overview").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.datasets.uploadBatches[0].statusCode").value("REJECTED"))
.andExpect(jsonPath("$.datasets.validationIssues.length()").isNumber())
.andExpect(jsonPath("$.datasets.validationIssues[0].issueCode").exists());
}
private MockHttpSession login(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "%s",
"password": "%s"
}
""".formatted(username, password)))
.andExpect(status().isOk())
.andReturn();
return (MockHttpSession) result.getRequest().getSession(false);
}
}

View File

@ -0,0 +1,16 @@
spring:
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:hanwha_demo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
username: sa
password:
flyway:
enabled: true
app:
storage:
provider: local
local-root: ./build/test-storage
batch:
enabled: false

26
spec/nexacro/app.yaml Normal file
View File

@ -0,0 +1,26 @@
projectName: HanwhaNexacroDemo
applicationId: hanwha.demo.app
appTitle: Hanwha Nexacro Demo
previewTitle: Hanwha Nexacro Demo Preview
themeId: theme::demo
apiBase: /api
layout:
width: 1440
height: 900
service:
apiServiceId: svcApi
apiUrl: /api
fileServiceId: svcFile
fileUrl: /api
sharedDatasets:
- id: dsSession
columns:
- { id: username, type: STRING }
- { id: roleCode, type: STRING }
- { id: fullName, type: STRING }
- id: dsMessage
columns:
- { id: level, type: STRING }
- { id: code, type: STRING }
- { id: message, type: STRING }

View File

@ -0,0 +1,72 @@
formId: frmConsolidation
title: 집계 실행
route: consolidation
authority: OPERATOR
layout:
width: 1440
height: 900
datasets:
- id: dsRuns
columns:
- { id: id, type: INT }
- { id: fiscalPeriod, type: STRING }
- { id: statusCode, type: STRING }
- { id: requestedBy, type: STRING }
- { id: requestedAt, type: STRING }
- { id: finishedAt, type: STRING }
- { id: summaryMessage, type: STRING }
components:
- id: edtRunFiscalPeriod
type: Edit
prompt: 회계기간 (YYYY-MM)
left: 36
top: 108
width: 180
height: 38
- id: btnRun
type: Button
text: 집계 실행
left: 236
top: 108
width: 140
height: 38
- id: btnReloadRuns
type: Button
text: 상태 새로고침
left: 392
top: 108
width: 160
height: 38
grids:
- id: grdRuns
title: 집계 실행 이력
dataset: dsRuns
left: 36
top: 176
width: 1368
height: 360
columns:
- { id: id, text: 실행ID }
- { id: fiscalPeriod, text: 회계기간 }
- { id: statusCode, text: 상태 }
- { id: requestedBy, text: 요청자 }
- { id: requestedAt, text: 요청시각 }
- { id: finishedAt, text: 완료시각 }
- { id: summaryMessage, text: 요약 }
transactions:
- id: txLoadRuns
method: GET
endpoint: /api/tx/consolidations/overview
datasets:
- dsRuns
actions:
- id: actRunConsolidation
label: 집계 실행
method: POST
endpoint: /api/consolidations/runs
successMessage: 집계가 요청되었습니다. batch 컨테이너가 처리합니다.
messages:
- code: RUN_HINT
level: INFO
text: 업로드가 ACCEPTED 상태인 파일만 집계 대상에 포함됩니다.

61
spec/nexacro/login.yaml Normal file
View File

@ -0,0 +1,61 @@
formId: frmLogin
title: 로그인
route: login
authority: PUBLIC
layout:
width: 1440
height: 900
datasets:
- id: dsLogin
columns:
- { id: username, type: STRING }
- { id: password, type: STRING }
components:
- id: staTitle
type: Static
text: Hanwha Nexacro Demo
left: 88
top: 72
width: 460
height: 64
- id: staSubtitle
type: Static
text: 업로드/검증 중심 재무 통합 데모
left: 88
top: 148
width: 460
height: 28
- id: edtUsername
type: Edit
prompt: 사용자 ID
left: 88
top: 248
width: 320
height: 44
bind: dsLogin.username
- id: edtPassword
type: Edit
prompt: 비밀번호
left: 88
top: 308
width: 320
height: 44
bind: dsLogin.password
- id: btnLogin
type: Button
text: 로그인
left: 88
top: 376
width: 320
height: 48
actions:
- id: actLogin
label: 로그인
method: POST
endpoint: /api/auth/login
successMessage: 로그인되었습니다.
messages:
- code: LOGIN_HINT
level: INFO
text: 기본 계정 admin/operator/viewer, 비밀번호 demo1234

View File

@ -0,0 +1,89 @@
formId: frmMasterData
title: 기준정보 관리
route: master
authority: VIEWER
layout:
width: 1440
height: 900
datasets:
- id: dsEntity
columns:
- { id: entityCode, type: STRING }
- { id: entityName, type: STRING }
- { id: baseCurrency, type: STRING }
- id: dsAccount
columns:
- { id: accountCode, type: STRING }
- { id: accountName, type: STRING }
- { id: accountCategory, type: STRING }
- { id: internalTradeYn, type: STRING }
- id: dsFxRate
columns:
- { id: fiscalPeriod, type: STRING }
- { id: currencyCode, type: STRING }
- { id: rateToKrw, type: BIGDECIMAL }
- id: dsOwnership
columns:
- { id: parentEntityCode, type: STRING }
- { id: childEntityCode, type: STRING }
- { id: ownershipRatio, type: BIGDECIMAL }
grids:
- id: grdEntity
title: 법인정보
dataset: dsEntity
left: 36
top: 108
width: 640
height: 260
columns:
- { id: entityCode, text: 법인코드 }
- { id: entityName, text: 법인명 }
- { id: baseCurrency, text: 기준통화 }
- id: grdAccount
title: 계정코드
dataset: dsAccount
left: 708
top: 108
width: 696
height: 260
columns:
- { id: accountCode, text: 계정코드 }
- { id: accountName, text: 계정명 }
- { id: accountCategory, text: 분류 }
- { id: internalTradeYn, text: 내부거래 }
- id: grdFxRate
title: 환율정보
dataset: dsFxRate
left: 36
top: 410
width: 640
height: 260
columns:
- { id: fiscalPeriod, text: 회계기간 }
- { id: currencyCode, text: 통화 }
- { id: rateToKrw, text: KRW 환산율 }
- id: grdOwnership
title: 지분율
dataset: dsOwnership
left: 708
top: 410
width: 696
height: 260
columns:
- { id: parentEntityCode, text: 모법인 }
- { id: childEntityCode, text: 자법인 }
- { id: ownershipRatio, text: 지분율 }
transactions:
- id: txLoadReference
method: GET
endpoint: /api/tx/master/reference
datasets:
- dsEntity
- dsAccount
- dsFxRate
- dsOwnership
messages:
- code: MASTER_LOAD
level: INFO
text: 기준정보, 계정, 환율, 지분율을 확인합니다.

View File

@ -0,0 +1,63 @@
formId: frmReportsOps
title: 리포트/운영
route: reports
authority: VIEWER
layout:
width: 1440
height: 900
datasets:
- id: dsArtifacts
columns:
- { id: id, type: INT }
- { id: runId, type: INT }
- { id: artifactType, type: STRING }
- { id: downloadName, type: STRING }
- { id: createdAt, type: STRING }
- id: dsJobLogs
columns:
- { id: id, type: INT }
- { id: jobType, type: STRING }
- { id: referenceId, type: INT }
- { id: logLevel, type: STRING }
- { id: logMessage, type: STRING }
- { id: createdAt, type: STRING }
grids:
- id: grdArtifacts
title: 리포트 산출물
dataset: dsArtifacts
left: 36
top: 108
width: 1368
height: 270
columns:
- { id: id, text: 산출물ID }
- { id: runId, text: 실행ID }
- { id: artifactType, text: 형식 }
- { id: downloadName, text: 파일명 }
- { id: createdAt, text: 생성시각 }
- id: grdJobLogs
title: 최근 배치 로그
dataset: dsJobLogs
left: 36
top: 420
width: 1368
height: 320
columns:
- { id: id, text: 로그ID }
- { id: jobType, text: 작업유형 }
- { id: referenceId, text: 참조ID }
- { id: logLevel, text: 레벨 }
- { id: logMessage, text: 메시지 }
- { id: createdAt, text: 생성시각 }
transactions:
- id: txLoadReports
method: GET
endpoint: /api/tx/reports/overview
datasets:
- dsArtifacts
- dsJobLogs
messages:
- code: REPORT_HINT
level: INFO
text: batch가 생성한 Excel/PDF를 내려받고 최근 로그를 확인합니다.

View File

@ -0,0 +1,107 @@
formId: frmUploadValidation
title: 업로드/검증
route: uploads
authority: OPERATOR
layout:
width: 1440
height: 900
datasets:
- id: dsUploadBatches
columns:
- { id: id, type: INT }
- { id: templateCode, type: STRING }
- { id: fiscalPeriod, type: STRING }
- { id: statusCode, type: STRING }
- { id: originalFilename, type: STRING }
- { id: rowCount, type: INT }
- { id: errorCount, type: INT }
- { id: uploadedAt, type: STRING }
- id: dsIssues
columns:
- { id: rowNumber, type: INT }
- { id: issueCode, type: STRING }
- { id: issueMessage, type: STRING }
- { id: severityCode, type: STRING }
components:
- id: cboTemplate
type: Combo
prompt: 템플릿 구분
left: 36
top: 108
width: 220
height: 38
- id: edtFiscalPeriod
type: Edit
prompt: 회계기간 (YYYY-MM)
left: 274
top: 108
width: 140
height: 38
- id: fileUpload
type: FileUpload
prompt: 업로드 파일
left: 432
top: 108
width: 480
height: 38
- id: btnUpload
type: Button
text: 파일 업로드
left: 930
top: 108
width: 140
height: 38
- id: btnReloadUploads
type: Button
text: 내역 새로고침
left: 1086
top: 108
width: 140
height: 38
grids:
- id: grdUploadBatches
title: 업로드 이력
dataset: dsUploadBatches
left: 36
top: 176
width: 1368
height: 270
columns:
- { id: id, text: 배치ID }
- { id: templateCode, text: 템플릿 }
- { id: fiscalPeriod, text: 회계기간 }
- { id: statusCode, text: 상태 }
- { id: originalFilename, text: 파일명 }
- { id: rowCount, text: 건수 }
- { id: errorCount, text: 오류건수 }
- { id: uploadedAt, text: 업로드시각 }
- id: grdIssues
title: 오류내역
dataset: dsIssues
left: 36
top: 486
width: 1368
height: 300
columns:
- { id: rowNumber, text: 행번호 }
- { id: issueCode, text: 오류코드 }
- { id: issueMessage, text: 오류메시지 }
- { id: severityCode, text: 등급 }
transactions:
- id: txLoadUploadOverview
method: GET
endpoint: /api/tx/uploads/overview
datasets:
- dsUploadBatches
- dsIssues
actions:
- id: actUpload
label: 파일 업로드
method: POST
endpoint: /api/uploads
successMessage: 업로드 요청이 접수되었습니다.
messages:
- code: UPLOAD_HINT
level: INFO
text: invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다.

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Application id="{{applicationId}}" titletext="{{appTitle}}" mainframeurl="./frame/MainFrame.xfdl">
<Frames>
<MainFrame id="mainframe" left="0" top="0" width="{{width}}" height="{{height}}" formurl="./forms/frmLogin.xfdl"/>
</Frames>
</Application>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<AppVariables>
<Variable id="g_apiBase" value="{{apiBase}}"/>
<Variable id="g_appTitle" value="{{appTitle}}"/>
</AppVariables>

View File

@ -0,0 +1,15 @@
this.gfnApiBase = function()
{
return application.g_apiBase || "/api";
};
this.gfnBuildTransactionUrl = function(path)
{
return this.gfnApiBase() + path;
};
this.gfnShowMessage = function(message)
{
trace(message);
};

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<Environment version="2.0" id="environment" themeid="{{themeId}}">
<Screen id="desktop" width="{{width}}" height="{{height}}"/>
<Services>
<Service id="{{apiServiceId}}" url="{{apiUrl}}" type="http"/>
<Service id="{{fileServiceId}}" url="{{fileUrl}}" type="http"/>
</Services>
</Environment>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Project version="2.0" name="{{projectName}}">
<Environment url="./environment.xml"/>
<TypeDefinition url="./typedefinition.xml"/>
<AppVariables url="./appvariables.xml"/>
<Application url="./application.xadl"/>
<Forms>
{{formEntries}}
</Forms>
</Project>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<TypeDefinition version="2.0">
<Components>
<Component id="Button" classname="nexacro.Button"/>
<Component id="Static" classname="nexacro.Static"/>
<Component id="Edit" classname="nexacro.Edit"/>
<Component id="Div" classname="nexacro.Div"/>
<Component id="Grid" classname="nexacro.Grid"/>
<Component id="Combo" classname="nexacro.Combo"/>
<Component id="FileUpload" classname="nexacro.FileUpload"/>
</Components>
</TypeDefinition>

1058
tools/nexacro-gen/index.js Normal file

File diff suppressed because it is too large Load Diff

33
tools/nexacro-gen/package-lock.json generated Normal file
View File

@ -0,0 +1,33 @@
{
"name": "nexacro-gen",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nexacro-gen",
"version": "1.0.0",
"dependencies": {
"js-yaml": "^4.1.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "nexacro-gen",
"version": "1.0.0",
"private": true,
"description": "Spec driven Nexacro source generator",
"scripts": {
"generate": "node index.js",
"test": "node --test"
},
"dependencies": {
"js-yaml": "^4.1.0"
}
}

View File

@ -0,0 +1,47 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadSpecs, generateProjectFiles, generatePreview, renderXfdl } = require("../index");
test("spec loader discovers app and form specs", () => {
const { appSpec, forms } = loadSpecs();
assert.equal(appSpec.projectName, "HanwhaNexacroDemo");
assert.equal(forms.length, 5);
assert.ok(forms.some((form) => form.formId === "frmUploadValidation"));
});
test("project generator writes required Nexacro files", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nexacro-src-"));
const { appSpec, forms } = loadSpecs();
generateProjectFiles(appSpec, forms, tempDir);
assert.ok(fs.existsSync(path.join(tempDir, "HanwhaNexacroDemo.xprj")));
assert.ok(fs.existsSync(path.join(tempDir, "application.xadl")));
assert.ok(fs.existsSync(path.join(tempDir, "environment.xml")));
assert.ok(fs.existsSync(path.join(tempDir, "typedefinition.xml")));
assert.ok(fs.existsSync(path.join(tempDir, "appvariables.xml")));
assert.ok(fs.existsSync(path.join(tempDir, "forms", "frmLogin.xfdl")));
assert.ok(fs.readFileSync(path.join(tempDir, "HanwhaNexacroDemo.xprj"), "utf8").includes("./forms/frmLogin.xfdl"));
});
test("preview generator emits static assets", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nexacro-preview-"));
const { appSpec, forms } = loadSpecs();
generatePreview(appSpec, forms, tempDir);
assert.ok(fs.existsSync(path.join(tempDir, "index.html")));
assert.ok(fs.existsSync(path.join(tempDir, "assets", "app.js")));
assert.ok(fs.readFileSync(path.join(tempDir, "assets", "app.js"), "utf8").includes("frmReportsOps"));
});
test("xfdl renderer includes datasets and components", () => {
const { appSpec, forms } = loadSpecs();
const xfdl = renderXfdl(forms.find((item) => item.formId === "frmLogin"), appSpec);
assert.ok(xfdl.includes("<Dataset id=\"dsLogin\">"));
assert.ok(xfdl.includes("<Edit id=\"edtUsername\""));
assert.ok(xfdl.includes("this.form_onload"));
});