Initial Hanwha Nexacro demo scaffold
This commit is contained in:
commit
d38d14ed8e
|
|
@ -0,0 +1,5 @@
|
|||
tools/nexacro-gen/node_modules/
|
||||
server/api/.gradle/
|
||||
server/api/build/
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -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는 생성된 소스를 열어 검수하고 웹 배포 산출물을 생성하는 용도로 사용한다.
|
||||
|
|
@ -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>`;
|
||||
});
|
||||
|
|
@ -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 샘플을 재업로드합니다."
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
@ -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에 반영한 후 다시 생성한다.
|
||||
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
|
|
@ -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` 업로드가 존재하는지 확인
|
||||
|
||||
|
|
@ -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`에 덮어쓸 수 있다.
|
||||
|
||||
|
|
@ -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.
|
|
@ -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()
|
||||
|
||||
|
|
@ -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"]
|
||||
|
||||
|
|
@ -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']
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
rootProject.name = "hanwha-nexacro-demo-api"
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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/**");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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, "산출물을 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.hanwha.nexacrodemo.minio;
|
||||
|
||||
public interface ObjectStorageService {
|
||||
|
||||
void putObject(String objectKey, byte[] bytes, String contentType);
|
||||
|
||||
StoredObject getObject(String objectKey);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
app:
|
||||
batch:
|
||||
enabled: true
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
@ -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', '전망 데이터 업로드');
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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 }
|
||||
|
||||
|
|
@ -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 상태인 파일만 집계 대상에 포함됩니다.
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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: 기준정보, 계정, 환율, 지분율을 확인합니다.
|
||||
|
||||
|
|
@ -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를 내려받고 최근 로그를 확인합니다.
|
||||
|
||||
|
|
@ -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 샘플을 재업로드합니다.
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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"));
|
||||
});
|
||||
Loading…
Reference in New Issue