Compare commits

..

No commits in common. "661d78a22521579545810474636c1f782d01ba0f" and "75a786f6817758d2d6d6aaa0b24533a0673a5f0d" have entirely different histories.

24 changed files with 121 additions and 736 deletions

View File

@ -125,36 +125,22 @@ function table(columns, rows, options = {}) {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id]; const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return `<td>${formatValue(value)}</td>`; return `<td>${formatValue(value)}</td>`;
}) })
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>` .join("")}</tr>`
) )
.join("") .join("")
: `<tr><td colspan="${columns.length + (options.actions ? 1 : 0)}">데이터가 없습니다.</td></tr>`; : `<tr><td colspan="${columns.length}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap"><table><thead><tr>${columns return `<div class="table-wrap"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`) .map((column) => `<th>${column.text}</th>`)
.join("")}${options.actions ? "<th>작업</th>" : ""}</tr></thead><tbody>${body}</tbody></table></div>`; .join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
} }
function pill(value) { function pill(value) {
return `<span class="pill ${value}">${value}</span>`; return `<span class="pill ${value}">${value}</span>`;
} }
function hasRole(requiredRole) {
const roleOrder = {
PUBLIC: 0,
VIEWER: 10,
OPERATOR: 20,
ADMIN: 30
};
return (roleOrder[state.session?.roleCode] || 0) >= (roleOrder[requiredRole] || 0);
}
function isAdmin() { function isAdmin() {
return hasRole("ADMIN"); return state.session?.roleCode === "ADMIN";
}
function isOperator() {
return hasRole("OPERATOR");
} }
function cloneRows(rows) { function cloneRows(rows) {
@ -432,11 +418,11 @@ function renderUploads() {
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></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"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions"> <div class="row actions">
<button data-action="upload" ${isOperator() ? "" : "disabled"}>파일 업로드</button> <button data-action="upload">파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button> <button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-invalid">오류 샘플</button> <a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button> <a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 Forecast</button> <a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
@ -459,9 +445,6 @@ function renderUploads() {
return pill(value); return pill(value);
} }
return value; return value;
},
actions(row) {
return `<button class="danger small" data-action="delete-upload" data-batch-id="${row.id}" ${isOperator() ? "" : "disabled"}>삭제</button>`;
} }
} }
)} )}
@ -604,6 +587,14 @@ function shellContent(content) {
</div> </div>
</aside> </aside>
<main class="main"> <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} ${content}
</main> </main>
</div> </div>
@ -800,9 +791,6 @@ function bindEvents() {
const uploadButton = document.querySelector("[data-action='upload']"); const uploadButton = document.querySelector("[data-action='upload']");
if (uploadButton) { if (uploadButton) {
uploadButton.addEventListener("click", async () => { uploadButton.addEventListener("click", async () => {
if (!isOperator()) {
return;
}
const templateCode = document.getElementById("upload-template").value; const templateCode = document.getElementById("upload-template").value;
const fiscalPeriod = document.getElementById("upload-period").value; const fiscalPeriod = document.getElementById("upload-period").value;
const file = document.getElementById("upload-file").files[0]; const file = document.getElementById("upload-file").files[0];
@ -828,30 +816,6 @@ function bindEvents() {
}); });
} }
document.querySelectorAll("[data-action='download-sample']").forEach((button) => {
button.addEventListener("click", () => {
const fiscalPeriod = document.getElementById("upload-period")?.value?.trim() || "2026-03";
const sampleCode = button.dataset.sampleCode;
window.location.href = `/api/uploads/samples/${sampleCode}/download?fiscalPeriod=${encodeURIComponent(fiscalPeriod)}`;
});
});
document.querySelectorAll("[data-action='delete-upload']").forEach((button) => {
button.addEventListener("click", async () => {
if (!isOperator()) {
return;
}
const batchId = button.dataset.batchId;
const confirmed = window.confirm(`업로드 이력 ${batchId}번을 삭제하시겠습니까? 관련 오류내역과 업로드 행도 함께 삭제됩니다.`);
if (!confirmed) {
return;
}
await api(`/api/uploads/${batchId}`, { method: "DELETE" });
await loadUploads();
render();
});
});
const runButton = document.querySelector("[data-action='request-run']"); const runButton = document.querySelector("[data-action='request-run']");
if (runButton) { if (runButton) {
runButton.addEventListener("click", async () => { runButton.addEventListener("click", async () => {

View File

@ -270,17 +270,13 @@ th {
font-weight: 700; font-weight: 700;
} }
.actions-cell {
white-space: nowrap;
width: 88px;
}
.master-table input { .master-table input {
min-width: 110px; min-width: 110px;
padding: 10px 12px; padding: 10px 12px;
} }
.master-table td.actions-cell { .master-table td.actions-cell {
white-space: nowrap;
width: 88px; width: 88px;
} }

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<FDL version="2.1"> <FDL version="2.1">
<Form id="Form_Work" width="1200" height="844" titletext="Form_Work"> <Form id="Form_Work" width="1200" height="844" titletext="Form_Work">
<Div id="divWork" taborder="0" left="0" top="0" width="1200" height="844" url="Base::frmLogin.xfdl"/> <Div id="divWork" taborder="0" left="0" top="0" width="1200" height="844" url="Forms::frmLogin.xfdl"/>
<Layouts> <Layouts>
<Layout width="1200" height="844" screenid="Desktop_screen"/> <Layout width="1200" height="844" screenid="Desktop_screen"/>
</Layouts> </Layouts>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project version="2.1" nexacrosdk="Latest Version" sdkversion=""> <Project version="2.1" nexacrosdk="Latest Version" sdkversion="">
<EnvironmentDefinition url="environment.xml"/> <EnvironmentDefinition url="/environment.xml"/>
<TypeDefinition url="typedefinition.xml"/> <TypeDefinition url="/typedefinition.xml"/>
<AppVariables url="appvariables.xml"/> <AppVariables url="/appvariables.xml"/>
<AppInfos> <AppInfos>
<AppInfo url="Application_Desktop.xadl"/> <AppInfo url="/Application_Desktop.xadl"/>
</AppInfos> </AppInfos>
</Project> </Project>

View File

@ -52,7 +52,6 @@ this.actRunConsolidation = function(obj, e)
<Grid id="grdRuns" taborder="1" left="36" top="176" width="1368" height="360" binddataset="dsRuns"> <Grid id="grdRuns" taborder="1" 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> <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> </Grid>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="업로드가 ACCEPTED 상태인 파일만 집계 대상에 포함됩니다."/>
<Layouts> <Layouts>
<Layout width="1440" height="900" screenid="Desktop_screen"/> <Layout width="1440" height="900" screenid="Desktop_screen"/>
</Layouts> </Layouts>

View File

@ -39,11 +39,10 @@ this.actLogin = function(obj, e)
]]></Script> ]]></Script>
<Static id="staTitle" taborder="0" left="88" top="72" width="460" height="64" text="Hanwha Nexacro Demo"/> <Static id="staTitle" taborder="0" left="88" top="72" width="460" height="64" text="Hanwha Nexacro Demo"/>
<Static id="staSubtitle" taborder="1" left="88" top="148" width="460" height="28" text="업로드/검증 중심 재무 통합 데모"/> <Static id="staSubtitle" taborder="1" left="88" top="148" width="460" height="28" text="업로드/검증 중심 재무 통합 데모"/>
<Edit id="edtUsername" taborder="2" left="88" top="248" width="320" height="44" displaynulltext="사용자 ID"/> <Edit id="edtUsername" taborder="2" left="88" top="248" width="320" height="44" displaynulltext="사용자 ID" value="bind:username"/>
<Edit id="edtPassword" taborder="3" left="88" top="308" width="320" height="44" displaynulltext="비밀번호" password="true"/> <Edit id="edtPassword" taborder="3" left="88" top="308" width="320" height="44" displaynulltext="비밀번호" value="bind:password" password="true"/>
<Button id="btnLogin" taborder="4" left="88" top="376" width="320" height="48" text="로그인"/> <Button id="btnLogin" taborder="4" left="88" top="376" width="320" height="48" text="로그인"/>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="기본 계정 admin/operator/viewer, 비밀번호 demo1234"/>
<Layouts> <Layouts>
<Layout width="1440" height="900" screenid="Desktop_screen"/> <Layout width="1440" height="900" screenid="Desktop_screen"/>
</Layouts> </Layouts>

View File

@ -75,7 +75,6 @@ this.txLoadReference = function(obj, e)
<Grid id="grdOwnership" taborder="1" left="708" top="410" width="696" height="260" binddataset="dsOwnership"> <Grid id="grdOwnership" taborder="1" 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> <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> </Grid>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="기준정보, 계정, 환율, 지분율을 확인합니다."/>
<Layouts> <Layouts>
<Layout width="1440" height="900" screenid="Desktop_screen"/> <Layout width="1440" height="900" screenid="Desktop_screen"/>
</Layouts> </Layouts>

View File

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

View File

@ -56,7 +56,7 @@ this.actUpload = function(obj, e)
]]></Script> ]]></Script>
<Combo id="cboTemplate" taborder="0" left="36" top="108" width="220" height="38" codecolumn="code" datacolumn="label"/> <Combo id="cboTemplate" taborder="0" left="36" top="108" width="220" height="38" codecolumn="code" datacolumn="label"/>
<Edit id="edtFiscalPeriod" taborder="1" left="274" top="108" width="140" height="38" displaynulltext="회계기간 (YYYY-MM)"/> <Edit id="edtFiscalPeriod" taborder="1" left="274" top="108" width="140" height="38" displaynulltext="회계기간 (YYYY-MM)"/>
<Edit id="fileUpload" taborder="2" left="432" top="108" width="480" height="38" displaynulltext="업로드 파일" readonly="true"/> <FileUpload id="fileUpload" taborder="2" left="432" top="108" width="480" height="38"/>
<Button id="btnUpload" taborder="3" left="930" top="108" width="140" height="38" text="파일 업로드"/> <Button id="btnUpload" taborder="3" left="930" top="108" width="140" height="38" text="파일 업로드"/>
<Button id="btnReloadUploads" taborder="4" left="1086" top="108" width="140" height="38" text="내역 새로고침"/> <Button id="btnReloadUploads" taborder="4" left="1086" top="108" width="140" height="38" text="내역 새로고침"/>
<Static id="sta_grdUploadBatches" taborder="0" left="36" top="148" width="1368" height="24" text="업로드 이력"/> <Static id="sta_grdUploadBatches" taborder="0" left="36" top="148" width="1368" height="24" text="업로드 이력"/>
@ -67,7 +67,6 @@ this.actUpload = function(obj, e)
<Grid id="grdIssues" taborder="1" left="36" top="486" width="1368" height="300" binddataset="dsIssues"> <Grid id="grdIssues" taborder="1" left="36" top="486" width="1368" height="300" binddataset="dsIssues">
<Formats><Format id="default"><Columns><Column size="342"/><Column size="342"/><Column size="342"/><Column size="342"/></Columns><Rows><Row size="32" band="head"/><Row size="28"/></Rows><Band id="head"><Cell col="0" text="행번호"/><Cell col="1" text="오류코드"/><Cell col="2" text="오류메시지"/><Cell col="3" text="등급"/></Band><Band id="body"><Cell col="0" text="bind:rowNumber"/><Cell col="1" text="bind:issueCode"/><Cell col="2" text="bind:issueMessage"/><Cell col="3" text="bind:severityCode"/></Band></Format></Formats> <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> </Grid>
<Static id="staMessage0" taborder="900" left="36" top="824" width="1368" height="20" text="invalid 샘플로 오류 시나리오를 확인한 뒤 valid 샘플을 재업로드합니다."/>
<Layouts> <Layouts>
<Layout width="1440" height="900" screenid="Desktop_screen"/> <Layout width="1440" height="900" screenid="Desktop_screen"/>
</Layouts> </Layouts>

View File

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

View File

@ -41,6 +41,8 @@
<Component type="JavaScript" id="DateField" classname="nexacro.DateField"/> <Component type="JavaScript" id="DateField" classname="nexacro.DateField"/>
<Component type="JavaScript" id="DateRangePicker" classname="nexacro.DateRangePicker"/> <Component type="JavaScript" id="DateRangePicker" classname="nexacro.DateRangePicker"/>
<Component type="JavaScript" id="PopupDateRangePicker" classname="nexacro.PopupDateRangePicker"/> <Component type="JavaScript" id="PopupDateRangePicker" classname="nexacro.PopupDateRangePicker"/>
<Component type="JavaScript" id="View" classname="nexacro.View"/>
<Component type="JavaScript" id="FileUpload" classname="nexacro.FileUpload"/>
</Components> </Components>
<Services> <Services>
<Service prefixid="theme" type="resource" url="./_resource_/_theme_/" version="0" cachelevel="session" include_subdir="true"/> <Service prefixid="theme" type="resource" url="./_resource_/_theme_/" version="0" cachelevel="session" include_subdir="true"/>
@ -50,8 +52,8 @@
<Service prefixid="font" type="resource" url="./_resource_/_font_/" version="0" cachelevel="session" include_subdir="false"/> <Service prefixid="font" type="resource" url="./_resource_/_font_/" version="0" cachelevel="session" include_subdir="false"/>
<Service prefixid="stringrc" type="resource" url="./_resource_/_stringrc_/" version="0" cachelevel="session" include_subdir="false"/> <Service prefixid="stringrc" type="resource" url="./_resource_/_stringrc_/" version="0" cachelevel="session" include_subdir="false"/>
<Service prefixid="extlib" type="resource" url="./_extlib_/" version="0" cachelevel="session" include_subdir="true"/> <Service prefixid="extlib" type="resource" url="./_extlib_/" version="0" cachelevel="session" include_subdir="true"/>
<Service prefixid="Base" type="form" url="./Base/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/>
<Service prefixid="FrameBase" type="form" url="./FrameBase/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/> <Service prefixid="FrameBase" type="form" url="./FrameBase/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/>
<Service prefixid="Forms" type="form" url="./forms/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/>
</Services> </Services>
<Protocols/> <Protocols/>
<DeviceAdaptors/> <DeviceAdaptors/>

View File

@ -8,36 +8,15 @@ import com.hanwha.nexacrodemo.consolidation.ConsolidationPayload;
import com.hanwha.nexacrodemo.minio.ObjectStorageService; import com.hanwha.nexacrodemo.minio.ObjectStorageService;
import com.hanwha.nexacrodemo.minio.StoredObject; import com.hanwha.nexacrodemo.minio.StoredObject;
import com.lowagie.text.Document; import com.lowagie.text.Document;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.FontFactory;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph; import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.Rectangle;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter; import com.lowagie.text.pdf.PdfWriter;
import java.awt.Color;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.FontUnderline;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -48,10 +27,6 @@ import org.springframework.stereotype.Service;
@Service @Service
public class ReportService { public class ReportService {
private static final DateTimeFormatter REPORT_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DecimalFormat AMOUNT_FORMAT = new DecimalFormat("#,##0.00");
private static final Map<String, String> LABELS = buildLabels();
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final ConsolidationMapper consolidationMapper; private final ConsolidationMapper consolidationMapper;
private final ObjectStorageService objectStorageService; private final ObjectStorageService objectStorageService;
@ -78,7 +53,7 @@ public class ReportService {
public void generateReports(Long runId, String fiscalPeriod, ConsolidationPayload payload) { public void generateReports(Long runId, String fiscalPeriod, ConsolidationPayload payload) {
try { try {
byte[] excelBytes = buildExcel(fiscalPeriod, payload); byte[] excelBytes = buildExcel(payload);
byte[] pdfBytes = buildPdf(fiscalPeriod, payload); byte[] pdfBytes = buildPdf(fiscalPeriod, payload);
ReportArtifactCommand excelArtifact = new ReportArtifactCommand(); ReportArtifactCommand excelArtifact = new ReportArtifactCommand();
@ -118,13 +93,19 @@ public class ReportService {
.body(new ByteArrayResource(storedObject.getBytes())); .body(new ByteArrayResource(storedObject.getBytes()));
} }
private byte[] buildExcel(String fiscalPeriod, ConsolidationPayload payload) throws IOException { private byte[] buildExcel(ConsolidationPayload payload) throws IOException {
try (XSSFWorkbook workbook = new XSSFWorkbook()) { try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Map<String, CellStyle> styles = createExcelStyles(workbook); Sheet summarySheet = workbook.createSheet("Summary");
buildSummarySheet(workbook, fiscalPeriod, payload, styles); int rowIndex = 0;
buildDetailSheet(workbook, "Contributions", fiscalPeriod, payload.getContributionRows(), styles); for (Map.Entry<String, BigDecimal> metric : payload.getMetrics().entrySet()) {
buildDetailSheet(workbook, "Eliminations", fiscalPeriod, payload.getEliminationRows(), styles); Row row = summarySheet.createRow(rowIndex++);
buildDetailSheet(workbook, "Forecast", fiscalPeriod, payload.getForecastRows(), styles); 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(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream); workbook.write(outputStream);
@ -134,352 +115,39 @@ public class ReportService {
private byte[] buildPdf(String fiscalPeriod, ConsolidationPayload payload) throws IOException { private byte[] buildPdf(String fiscalPeriod, ConsolidationPayload payload) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4.rotate(), 36, 36, 36, 36); Document document = new Document();
PdfWriter.getInstance(document, outputStream); PdfWriter.getInstance(document, outputStream);
document.open(); document.open();
addPdfTitle(document, "Hanwha Consolidation Summary", 18, new Color(24, 54, 93)); document.add(new Paragraph("Hanwha Consolidation Demo"));
addPdfTitle(document, "Fiscal period " + fiscalPeriod + " | Generated " + generatedAt(), 10, new Color(90, 96, 106)); document.add(new Paragraph("Fiscal period: " + fiscalPeriod));
document.add(new Paragraph(" ")); document.add(new Paragraph(" "));
addSummaryTable(document, payload.getMetrics()); for (Map.Entry<String, BigDecimal> metric : payload.getMetrics().entrySet()) {
addDetailTable(document, "Contributions", payload.getContributionRows(), 8); document.add(new Paragraph(metric.getKey() + ": " + metric.getValue()));
addDetailTable(document, "Eliminations", payload.getEliminationRows(), 8); }
addDetailTable(document, "Forecast", payload.getForecastRows(), 8);
document.close(); document.close();
return outputStream.toByteArray(); return outputStream.toByteArray();
} }
private void buildSummarySheet( private void writeRows(Sheet sheet, List<Map<String, Object>> rows) {
XSSFWorkbook workbook,
String fiscalPeriod,
ConsolidationPayload payload,
Map<String, CellStyle> styles
) {
Sheet sheet = workbook.createSheet("Summary");
sheet.setDisplayGridlines(false);
Row titleRow = sheet.createRow(0);
titleRow.setHeightInPoints(28);
createCell(titleRow, 0, "Hanwha Consolidation Summary", styles.get("title"));
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 3));
Row subtitleRow = sheet.createRow(1);
createCell(subtitleRow, 0, "Fiscal period " + fiscalPeriod + " | Generated " + generatedAt(), styles.get("subtitle"));
sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, 3));
Row sectionRow = sheet.createRow(3);
createCell(sectionRow, 0, "Performance Snapshot", styles.get("section"));
Row headerRow = sheet.createRow(4);
createCell(headerRow, 0, "Metric", styles.get("header"));
createCell(headerRow, 1, "Value", styles.get("header"));
int rowIndex = 5;
for (Map.Entry<String, BigDecimal> metric : payload.getMetrics().entrySet()) {
Row row = sheet.createRow(rowIndex++);
createCell(row, 0, label(metric.getKey()), styles.get("text"));
createNumericCell(row, 1, metric.getValue(), styleForKey(styles, metric.getKey()));
}
sheet.createFreezePane(0, 5);
sheet.setColumnWidth(0, 26 * 256);
sheet.setColumnWidth(1, 18 * 256);
sheet.setColumnWidth(2, 18 * 256);
sheet.setColumnWidth(3, 18 * 256);
}
private void buildDetailSheet(
XSSFWorkbook workbook,
String sheetName,
String fiscalPeriod,
List<Map<String, Object>> rows,
Map<String, CellStyle> styles
) {
Sheet sheet = workbook.createSheet(sheetName);
sheet.setDisplayGridlines(false);
int columnCount = rows.isEmpty() ? 1 : rows.get(0).size();
Row titleRow = sheet.createRow(0);
titleRow.setHeightInPoints(24);
createCell(titleRow, 0, sheetName + " Detail", styles.get("title"));
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, Math.max(0, columnCount - 1)));
Row subtitleRow = sheet.createRow(1);
createCell(subtitleRow, 0, "Fiscal period " + fiscalPeriod + " | Rows " + rows.size(), styles.get("subtitle"));
sheet.addMergedRegion(new CellRangeAddress(1, 1, 0, Math.max(0, columnCount - 1)));
if (rows.isEmpty()) { if (rows.isEmpty()) {
Row emptyRow = sheet.createRow(3); Row row = sheet.createRow(0);
createCell(emptyRow, 0, "No data available", styles.get("empty")); row.createCell(0).setCellValue("No data");
sheet.setColumnWidth(0, 24 * 256);
return; return;
} }
List<String> keys = List.copyOf(rows.get(0).keySet()); Row header = sheet.createRow(0);
Row headerRow = sheet.createRow(3); int cellIndex = 0;
for (int index = 0; index < keys.size(); index++) { for (String key : rows.get(0).keySet()) {
createCell(headerRow, index, label(keys.get(index)), styles.get("header")); header.createCell(cellIndex++).setCellValue(key);
} }
int rowIndex = 4; int rowIndex = 1;
for (Map<String, Object> item : rows) { for (Map<String, Object> item : rows) {
Row row = sheet.createRow(rowIndex++); Row row = sheet.createRow(rowIndex++);
for (int cellIndex = 0; cellIndex < keys.size(); cellIndex++) { int valueIndex = 0;
String key = keys.get(cellIndex); for (Object value : item.values()) {
writeValueCell(row, cellIndex, key, item.get(key), styles); row.createCell(valueIndex++).setCellValue(value == null ? "" : String.valueOf(value));
} }
} }
sheet.createFreezePane(0, 4);
sheet.setAutoFilter(new CellRangeAddress(3, rowIndex - 1, 0, keys.size() - 1));
for (int index = 0; index < keys.size(); index++) {
sheet.autoSizeColumn(index);
int currentWidth = sheet.getColumnWidth(index);
sheet.setColumnWidth(index, Math.min(currentWidth + 1024, 28 * 256));
}
}
private Map<String, CellStyle> createExcelStyles(XSSFWorkbook workbook) {
Map<String, CellStyle> styles = new LinkedHashMap<>();
short amountFormat = workbook.createDataFormat().getFormat("#,##0.00");
short percentFormat = workbook.createDataFormat().getFormat("0.00%");
var titleFont = workbook.createFont();
titleFont.setBold(true);
titleFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.WHITE.getIndex());
titleFont.setFontHeightInPoints((short) 14);
var subtitleFont = workbook.createFont();
subtitleFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_80_PERCENT.getIndex());
var sectionFont = workbook.createFont();
sectionFont.setBold(true);
sectionFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.DARK_BLUE.getIndex());
var headerFont = workbook.createFont();
headerFont.setBold(true);
headerFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.WHITE.getIndex());
var emptyFont = workbook.createFont();
emptyFont.setItalic(true);
emptyFont.setUnderline(FontUnderline.SINGLE);
emptyFont.setColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_50_PERCENT.getIndex());
CellStyle title = workbook.createCellStyle();
title.setFont(titleFont);
title.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.DARK_BLUE.getIndex());
title.setFillPattern(FillPatternType.SOLID_FOREGROUND);
title.setAlignment(HorizontalAlignment.LEFT);
title.setVerticalAlignment(VerticalAlignment.CENTER);
title.setBorderBottom(BorderStyle.THIN);
styles.put("title", title);
CellStyle subtitle = workbook.createCellStyle();
subtitle.setFont(subtitleFont);
subtitle.setAlignment(HorizontalAlignment.LEFT);
styles.put("subtitle", subtitle);
CellStyle section = workbook.createCellStyle();
section.setFont(sectionFont);
section.setAlignment(HorizontalAlignment.LEFT);
styles.put("section", section);
CellStyle header = workbook.createCellStyle();
header.setFont(headerFont);
header.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ORANGE.getIndex());
header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
header.setAlignment(HorizontalAlignment.CENTER);
header.setVerticalAlignment(VerticalAlignment.CENTER);
applyBorder(header);
styles.put("header", header);
CellStyle text = workbook.createCellStyle();
text.setAlignment(HorizontalAlignment.LEFT);
text.setVerticalAlignment(VerticalAlignment.CENTER);
applyBorder(text);
styles.put("text", text);
CellStyle number = workbook.createCellStyle();
number.cloneStyleFrom(text);
number.setAlignment(HorizontalAlignment.RIGHT);
number.setDataFormat(amountFormat);
styles.put("number", number);
CellStyle ratio = workbook.createCellStyle();
ratio.cloneStyleFrom(number);
ratio.setDataFormat(percentFormat);
styles.put("ratio", ratio);
CellStyle empty = workbook.createCellStyle();
empty.setFont(emptyFont);
styles.put("empty", empty);
return styles;
}
private void applyBorder(CellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
}
private void writeValueCell(Row row, int cellIndex, String key, Object value, Map<String, CellStyle> styles) {
if (value instanceof BigDecimal decimal) {
createNumericCell(row, cellIndex, decimal, styleForKey(styles, key));
return;
}
if (value instanceof Number number) {
createNumericCell(row, cellIndex, BigDecimal.valueOf(number.doubleValue()), styleForKey(styles, key));
return;
}
createCell(row, cellIndex, value == null ? "" : String.valueOf(value), styles.get("text"));
}
private void createCell(Row row, int index, String value, CellStyle style) {
var cell = row.createCell(index);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void createNumericCell(Row row, int index, BigDecimal value, CellStyle style) {
var cell = row.createCell(index);
cell.setCellValue(value.doubleValue());
cell.setCellStyle(style);
}
private CellStyle styleForKey(Map<String, CellStyle> styles, String key) {
return key.toLowerCase().contains("ratio") ? styles.get("ratio") : styles.get("number");
}
private void addPdfTitle(Document document, String text, int size, Color color) {
Font font = FontFactory.getFont(FontFactory.HELVETICA_BOLD, size, color);
Paragraph paragraph = new Paragraph(text, font);
paragraph.setAlignment(Element.ALIGN_LEFT);
paragraph.setSpacingAfter(4f);
document.add(paragraph);
}
private void addSummaryTable(Document document, Map<String, BigDecimal> metrics) {
PdfPTable table = new PdfPTable(new float[] {3f, 1.3f});
table.setWidthPercentage(100f);
table.setSpacingBefore(6f);
table.setSpacingAfter(12f);
table.addCell(pdfHeaderCell("Metric"));
table.addCell(pdfHeaderCell("Value"));
for (Map.Entry<String, BigDecimal> metric : metrics.entrySet()) {
table.addCell(pdfBodyCell(label(metric.getKey()), Element.ALIGN_LEFT));
table.addCell(pdfBodyCell(format(metric.getValue(), metric.getKey()), Element.ALIGN_RIGHT));
}
document.add(table);
}
private void addDetailTable(Document document, String title, List<Map<String, Object>> rows, int maxRows) {
Font sectionFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, new Color(24, 54, 93));
Paragraph section = new Paragraph(title, sectionFont);
section.setSpacingBefore(8f);
section.setSpacingAfter(6f);
document.add(section);
if (rows.isEmpty()) {
document.add(new Paragraph("No data available", FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, new Color(120, 120, 120))));
return;
}
List<String> keys = List.copyOf(rows.get(0).keySet());
PdfPTable table = new PdfPTable(keys.size());
table.setWidthPercentage(100f);
table.setSpacingAfter(8f);
for (String key : keys) {
table.addCell(pdfHeaderCell(label(key)));
}
int visibleRows = Math.min(maxRows, rows.size());
for (int rowIndex = 0; rowIndex < visibleRows; rowIndex++) {
Map<String, Object> row = rows.get(rowIndex);
for (String key : keys) {
int alignment = isNumericValue(row.get(key)) ? Element.ALIGN_RIGHT : Element.ALIGN_LEFT;
table.addCell(pdfBodyCell(format(row.get(key), key), alignment));
}
}
document.add(table);
if (rows.size() > maxRows) {
document.add(new Paragraph(
"Showing first " + maxRows + " of " + rows.size() + " rows.",
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 9, new Color(120, 120, 120))
));
}
}
private PdfPCell pdfHeaderCell(String text) {
PdfPCell cell = new PdfPCell(new Phrase(text, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE)));
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
cell.setBackgroundColor(new Color(243, 127, 32));
cell.setBorderColor(new Color(220, 220, 220));
cell.setPadding(6f);
return cell;
}
private PdfPCell pdfBodyCell(String text, int alignment) {
PdfPCell cell = new PdfPCell(new Phrase(text, FontFactory.getFont(FontFactory.HELVETICA, 9, Color.DARK_GRAY)));
cell.setHorizontalAlignment(alignment);
cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
cell.setBorderColor(new Color(226, 229, 233));
cell.setPadding(5f);
return cell;
}
private boolean isNumericValue(Object value) {
return value instanceof Number || value instanceof BigDecimal;
}
private String format(Object value, String key) {
if (value == null) {
return "";
}
if (value instanceof BigDecimal decimal) {
return format(decimal, key);
}
if (value instanceof Number number) {
return format(BigDecimal.valueOf(number.doubleValue()), key);
}
return String.valueOf(value);
}
private String format(BigDecimal value, String key) {
if (key.toLowerCase().contains("ratio")) {
return AMOUNT_FORMAT.format(value.multiply(BigDecimal.valueOf(100))) + "%";
}
return AMOUNT_FORMAT.format(value);
}
private String label(String key) {
return LABELS.getOrDefault(key, key);
}
private String generatedAt() {
return ZonedDateTime.now(ZoneId.of("Asia/Seoul")).format(REPORT_TIME_FORMAT);
}
private static Map<String, String> buildLabels() {
Map<String, String> labels = new LinkedHashMap<>();
labels.put("acceptedTrialRows", "Accepted Trial Rows");
labels.put("acceptedForecastRows", "Accepted Forecast Rows");
labels.put("grossContributionKrw", "Gross Contribution (KRW)");
labels.put("eliminationKrw", "Elimination (KRW)");
labels.put("forecastKrw", "Forecast (KRW)");
labels.put("netContributionKrw", "Net Contribution (KRW)");
labels.put("entityCode", "Entity Code");
labels.put("accountCode", "Account Code");
labels.put("partnerEntityCode", "Partner Entity");
labels.put("translatedAmount", "Translated Amount");
labels.put("ownershipRatio", "Ownership Ratio");
labels.put("finalAmount", "Final Amount");
labels.put("internalTrade", "Internal Trade");
labels.put("eliminationAmount", "Elimination Amount");
labels.put("note", "Note");
labels.put("scenarioCode", "Scenario");
return labels;
} }
} }

View File

@ -9,7 +9,6 @@ import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -47,12 +46,6 @@ public class UploadController {
return uploadService.loadIssues(batchId); return uploadService.loadIssues(batchId);
} }
@DeleteMapping("/{batchId}")
public Map<String, Object> delete(@PathVariable Long batchId, HttpSession session) {
authService.requireRole(session, "OPERATOR");
return uploadService.deleteUploadBatch(batchId);
}
@GetMapping("/templates/{templateCode}/download") @GetMapping("/templates/{templateCode}/download")
public ResponseEntity<ByteArrayResource> templateDownload(@PathVariable String templateCode, HttpSession session) { public ResponseEntity<ByteArrayResource> templateDownload(@PathVariable String templateCode, HttpSession session) {
authService.requireRole(session, "VIEWER"); authService.requireRole(session, "VIEWER");
@ -62,18 +55,5 @@ public class UploadController {
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + templateCode + "-template.xlsx\"") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + templateCode + "-template.xlsx\"")
.body(resource); .body(resource);
} }
}
@GetMapping("/samples/{sampleCode}/download")
public ResponseEntity<ByteArrayResource> sampleDownload(
@PathVariable String sampleCode,
@RequestParam String fiscalPeriod,
HttpSession session
) {
authService.requireRole(session, "VIEWER");
ByteArrayResource resource = uploadService.downloadSample(sampleCode, fiscalPeriod);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + sampleCode + "-" + fiscalPeriod + ".xlsx\"")
.body(resource);
}
}

View File

@ -2,7 +2,6 @@ package com.hanwha.nexacrodemo.upload;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
@ -155,22 +154,5 @@ public interface UploadMapper {
order by ur.id order by ur.id
""") """)
List<Map<String, Object>> listAcceptedRows(@Param("templateCode") String templateCode, @Param("fiscalPeriod") String fiscalPeriod); List<Map<String, Object>> listAcceptedRows(@Param("templateCode") String templateCode, @Param("fiscalPeriod") String fiscalPeriod);
@Delete("""
delete from validation_issue
where batch_id = #{batchId}
""")
int deleteValidationIssuesByBatchId(Long batchId);
@Delete("""
delete from upload_row
where batch_id = #{batchId}
""")
int deleteUploadRowsByBatchId(Long batchId);
@Delete("""
delete from upload_batch
where id = #{batchId}
""")
int deleteUploadBatchById(Long batchId);
} }

View File

@ -26,7 +26,6 @@ import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@Service @Service
@ -134,35 +133,10 @@ public class UploadService {
return MapKeyUtils.camelizeList(uploadMapper.listValidationIssuesByBatchId(batchId)); return MapKeyUtils.camelizeList(uploadMapper.listValidationIssuesByBatchId(batchId));
} }
@Transactional
public Map<String, Object> deleteUploadBatch(Long batchId) {
Map<String, Object> batch = uploadMapper.findBatchById(batchId);
if (batch == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "삭제할 업로드 이력을 찾을 수 없습니다.");
}
uploadMapper.deleteValidationIssuesByBatchId(batchId);
uploadMapper.deleteUploadRowsByBatchId(batchId);
int deleted = uploadMapper.deleteUploadBatchById(batchId);
if (deleted != 1) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "업로드 이력 삭제에 실패했습니다.");
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("ok", true);
payload.put("batchId", batchId);
payload.put("message", "업로드 이력이 삭제되었습니다.");
return payload;
}
public ByteArrayResource downloadTemplate(String templateCode) { public ByteArrayResource downloadTemplate(String templateCode) {
return new ByteArrayResource(workbookTemplateService.createTemplate(templateCode)); return new ByteArrayResource(workbookTemplateService.createTemplate(templateCode));
} }
public ByteArrayResource downloadSample(String sampleCode, String fiscalPeriod) {
return new ByteArrayResource(workbookTemplateService.createSample(sampleCode, fiscalPeriod));
}
private void parseDataRows( private void parseDataRows(
String templateCode, String templateCode,
String fiscalPeriod, String fiscalPeriod,

View File

@ -5,7 +5,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet; import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@ -15,8 +14,6 @@ import org.springframework.stereotype.Service;
@Service @Service
public class WorkbookTemplateService { public class WorkbookTemplateService {
private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-\\d{2}$");
private static final Map<String, List<String>> HEADERS = Map.of( private static final Map<String, List<String>> HEADERS = Map.of(
"trial-balance", List.of("fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount"), "trial-balance", List.of("fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount"),
"forecast", List.of("fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue") "forecast", List.of("fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue")
@ -31,34 +28,6 @@ public class WorkbookTemplateService {
} }
public byte[] createTemplate(String templateCode) { public byte[] createTemplate(String templateCode) {
return createWorkbook(templateCode, List.of());
}
public byte[] createSample(String sampleCode, String fiscalPeriod) {
if (!PERIOD_PATTERN.matcher(fiscalPeriod).matches()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "회계기간 형식은 YYYY-MM 이어야 합니다.");
}
return switch (sampleCode) {
case "trial-balance-invalid" -> createWorkbook("trial-balance", List.of(
List.of(fiscalPeriod, "US1", "AR_INT", "", "USD", "100", "0"),
List.of(fiscalPeriod, "HQ", "REV_EXT", "", "KRW", "0", "70")
));
case "trial-balance-valid" -> createWorkbook("trial-balance", List.of(
List.of(fiscalPeriod, "US1", "AR_INT", "HQ", "USD", "100", "0"),
List.of(fiscalPeriod, "HQ", "AP_INT", "US1", "KRW", "0", "100"),
List.of(fiscalPeriod, "HQ", "REV_EXT", "", "KRW", "0", "200000"),
List.of(fiscalPeriod, "HQ", "EXP_OPEX", "", "KRW", "200000", "0")
));
case "forecast-valid" -> createWorkbook("forecast", List.of(
List.of(fiscalPeriod, "US1", "REV_EXT", "USD", "PLAN", "500"),
List.of(fiscalPeriod, "SG1", "EXP_OPEX", "SGD", "FORECAST", "100")
));
default -> throw new ApiException(HttpStatus.BAD_REQUEST, "지원하지 않는 샘플입니다: " + sampleCode);
};
}
private byte[] createWorkbook(String templateCode, List<List<String>> rows) {
try (XSSFWorkbook workbook = new XSSFWorkbook()) { try (XSSFWorkbook workbook = new XSSFWorkbook()) {
XSSFSheet metaSheet = workbook.createSheet("META"); XSSFSheet metaSheet = workbook.createSheet("META");
Row metaHeader = metaSheet.createRow(0); Row metaHeader = metaSheet.createRow(0);
@ -77,13 +46,6 @@ public class WorkbookTemplateService {
for (int index = 0; index < headers.size(); index++) { for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(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(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
workbook.write(outputStream); workbook.write(outputStream);
@ -93,3 +55,4 @@ public class WorkbookTemplateService {
} }
} }
} }

View File

@ -6,12 +6,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hanwha.nexacrodemo.upload.TestWorkbookFactory; import com.hanwha.nexacrodemo.upload.TestWorkbookFactory;
import java.io.ByteArrayInputStream;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@ -34,9 +29,6 @@ class ConsolidationIntegrationTest {
@Autowired @Autowired
private ConsolidationService consolidationService; private ConsolidationService consolidationService;
@Autowired
private ObjectMapper objectMapper;
@Test @Test
void validUploadsCanBeConsolidatedAndReported() throws Exception { void validUploadsCanBeConsolidatedAndReported() throws Exception {
MockHttpSession session = login("operator", "demo1234"); MockHttpSession session = login("operator", "demo1234");
@ -63,33 +55,10 @@ class ConsolidationIntegrationTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.datasets.runs[0].statusCode").value("SUCCESS")); .andExpect(jsonPath("$.datasets.runs[0].statusCode").value("SUCCESS"));
MvcResult overviewResult = mockMvc.perform(get("/api/tx/reports/overview").session(session)) mockMvc.perform(get("/api/tx/reports/overview").session(session))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.datasets.artifacts.length()").value(2)) .andExpect(jsonPath("$.datasets.artifacts.length()").value(2))
.andExpect(jsonPath("$.datasets.jobLogs[0].logLevel").exists()) .andExpect(jsonPath("$.datasets.jobLogs[0].logLevel").exists());
.andReturn();
JsonNode artifacts = objectMapper.readTree(overviewResult.getResponse().getContentAsByteArray())
.path("datasets")
.path("artifacts");
long excelArtifactId = -1L;
for (JsonNode artifact : artifacts) {
if ("EXCEL".equals(artifact.path("artifactType").asText())) {
excelArtifactId = artifact.path("id").asLong();
break;
}
}
Assertions.assertTrue(excelArtifactId > 0, "EXCEL artifact should exist");
MvcResult excelDownload = mockMvc.perform(get("/api/reports/{artifactId}/download", excelArtifactId).session(session))
.andExpect(status().isOk())
.andReturn();
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(excelDownload.getResponse().getContentAsByteArray()))) {
Assertions.assertEquals("Hanwha Consolidation Summary", workbook.getSheet("Summary").getRow(0).getCell(0).getStringCellValue());
Assertions.assertEquals("Entity Code", workbook.getSheet("Contributions").getRow(3).getCell(0).getStringCellValue());
Assertions.assertNotNull(workbook.getSheet("Forecast"));
}
} }
private void upload(MockHttpSession session, String templateCode, String fileName, byte[] content) throws Exception { private void upload(MockHttpSession session, String templateCode, String fileName, byte[] content) throws Exception {

View File

@ -1,15 +1,12 @@
package com.hanwha.nexacrodemo.upload; package com.hanwha.nexacrodemo.upload;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.io.ByteArrayInputStream;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@ -55,81 +52,6 @@ class UploadValidationIntegrationTest {
.andExpect(jsonPath("$.datasets.validationIssues[0].issueCode").exists()); .andExpect(jsonPath("$.datasets.validationIssues[0].issueCode").exists());
} }
@Test
void operatorCanDeleteUploadBatchAndRelatedIssuesDisappearFromOverview() throws Exception {
MockHttpSession session = login("operator", "demo1234");
MockMultipartFile file = new MockMultipartFile(
"file",
"trial-balance-invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
TestWorkbookFactory.trialBalanceInvalid()
);
MvcResult uploadResult = mockMvc.perform(multipart("/api/uploads")
.file(file)
.param("templateCode", "trial-balance")
.param("fiscalPeriod", "2026-03")
.session(session))
.andExpect(status().isOk())
.andReturn();
String uploadPayload = uploadResult.getResponse().getContentAsString();
String batchId = uploadPayload.replaceAll(".*\"id\":(\\d+).*", "$1");
mockMvc.perform(delete("/api/uploads/{batchId}", batchId).session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.batchId").value(Integer.parseInt(batchId)));
mockMvc.perform(get("/api/tx/uploads/overview").session(session))
.andExpect(status().isOk())
.andExpect(jsonPath("$.datasets.uploadBatches[?(@.id == " + batchId + ")]").isEmpty())
.andExpect(jsonPath("$.datasets.validationIssues[?(@.batchId == " + batchId + ")]").isEmpty());
}
@Test
void viewerCannotDeleteUploadBatch() throws Exception {
MockHttpSession operatorSession = login("operator", "demo1234");
MockHttpSession viewerSession = login("viewer", "demo1234");
MockMultipartFile file = new MockMultipartFile(
"file",
"trial-balance-invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
TestWorkbookFactory.trialBalanceInvalid()
);
MvcResult uploadResult = mockMvc.perform(multipart("/api/uploads")
.file(file)
.param("templateCode", "trial-balance")
.param("fiscalPeriod", "2026-03")
.session(operatorSession))
.andExpect(status().isOk())
.andReturn();
String uploadPayload = uploadResult.getResponse().getContentAsString();
String batchId = uploadPayload.replaceAll(".*\"id\":(\\d+).*", "$1");
mockMvc.perform(delete("/api/uploads/{batchId}", batchId).session(viewerSession))
.andExpect(status().isForbidden());
}
@Test
void sampleDownloadUsesRequestedFiscalPeriod() throws Exception {
MockHttpSession session = login("viewer", "demo1234");
MvcResult result = mockMvc.perform(get("/api/uploads/samples/trial-balance-valid/download")
.param("fiscalPeriod", "2026-04")
.session(session))
.andExpect(status().isOk())
.andReturn();
try (var workbook = WorkbookFactory.create(new ByteArrayInputStream(result.getResponse().getContentAsByteArray()))) {
var dataSheet = workbook.getSheet("DATA");
var firstDataRow = dataSheet.getRow(1);
org.junit.jupiter.api.Assertions.assertEquals("2026-04", firstDataRow.getCell(0).getStringCellValue());
}
}
private MockHttpSession login(String username, String password) throws Exception { private MockHttpSession login(String username, String password) throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login") MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)

View File

@ -57,36 +57,22 @@ function table(columns, rows, options = {}) {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id]; const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return `<td>${formatValue(value)}</td>`; return `<td>${formatValue(value)}</td>`;
}) })
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>` .join("")}</tr>`
) )
.join("") .join("")
: `<tr><td colspan="${columns.length + (options.actions ? 1 : 0)}">데이터가 없습니다.</td></tr>`; : `<tr><td colspan="${columns.length}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap"><table><thead><tr>${columns return `<div class="table-wrap"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`) .map((column) => `<th>${column.text}</th>`)
.join("")}${options.actions ? "<th>작업</th>" : ""}</tr></thead><tbody>${body}</tbody></table></div>`; .join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
} }
function pill(value) { function pill(value) {
return `<span class="pill ${value}">${value}</span>`; return `<span class="pill ${value}">${value}</span>`;
} }
function hasRole(requiredRole) {
const roleOrder = {
PUBLIC: 0,
VIEWER: 10,
OPERATOR: 20,
ADMIN: 30
};
return (roleOrder[state.session?.roleCode] || 0) >= (roleOrder[requiredRole] || 0);
}
function isAdmin() { function isAdmin() {
return hasRole("ADMIN"); return state.session?.roleCode === "ADMIN";
}
function isOperator() {
return hasRole("OPERATOR");
} }
function cloneRows(rows) { function cloneRows(rows) {
@ -364,11 +350,11 @@ function renderUploads() {
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></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"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions"> <div class="row actions">
<button data-action="upload" ${isOperator() ? "" : "disabled"}>파일 업로드</button> <button data-action="upload">파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button> <button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-invalid">오류 샘플</button> <a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button> <a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 Forecast</button> <a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
@ -391,9 +377,6 @@ function renderUploads() {
return pill(value); return pill(value);
} }
return value; return value;
},
actions(row) {
return `<button class="danger small" data-action="delete-upload" data-batch-id="${row.id}" ${isOperator() ? "" : "disabled"}>삭제</button>`;
} }
} }
)} )}
@ -536,6 +519,14 @@ function shellContent(content) {
</div> </div>
</aside> </aside>
<main class="main"> <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} ${content}
</main> </main>
</div> </div>
@ -732,9 +723,6 @@ function bindEvents() {
const uploadButton = document.querySelector("[data-action='upload']"); const uploadButton = document.querySelector("[data-action='upload']");
if (uploadButton) { if (uploadButton) {
uploadButton.addEventListener("click", async () => { uploadButton.addEventListener("click", async () => {
if (!isOperator()) {
return;
}
const templateCode = document.getElementById("upload-template").value; const templateCode = document.getElementById("upload-template").value;
const fiscalPeriod = document.getElementById("upload-period").value; const fiscalPeriod = document.getElementById("upload-period").value;
const file = document.getElementById("upload-file").files[0]; const file = document.getElementById("upload-file").files[0];
@ -760,30 +748,6 @@ function bindEvents() {
}); });
} }
document.querySelectorAll("[data-action='download-sample']").forEach((button) => {
button.addEventListener("click", () => {
const fiscalPeriod = document.getElementById("upload-period")?.value?.trim() || "2026-03";
const sampleCode = button.dataset.sampleCode;
window.location.href = `/api/uploads/samples/${sampleCode}/download?fiscalPeriod=${encodeURIComponent(fiscalPeriod)}`;
});
});
document.querySelectorAll("[data-action='delete-upload']").forEach((button) => {
button.addEventListener("click", async () => {
if (!isOperator()) {
return;
}
const batchId = button.dataset.batchId;
const confirmed = window.confirm(`업로드 이력 ${batchId}번을 삭제하시겠습니까? 관련 오류내역과 업로드 행도 함께 삭제됩니다.`);
if (!confirmed) {
return;
}
await api(`/api/uploads/${batchId}`, { method: "DELETE" });
await loadUploads();
render();
});
});
const runButton = document.querySelector("[data-action='request-run']"); const runButton = document.querySelector("[data-action='request-run']");
if (runButton) { if (runButton) {
runButton.addEventListener("click", async () => { runButton.addEventListener("click", async () => {

View File

@ -270,17 +270,13 @@ th {
font-weight: 700; font-weight: 700;
} }
.actions-cell {
white-space: nowrap;
width: 88px;
}
.master-table input { .master-table input {
min-width: 110px; min-width: 110px;
padding: 10px 12px; padding: 10px 12px;
} }
.master-table td.actions-cell { .master-table td.actions-cell {
white-space: nowrap;
width: 88px; width: 88px;
} }

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project version="2.1" nexacrosdk="Latest Version" sdkversion=""> <Project version="2.1" nexacrosdk="Latest Version" sdkversion="">
<EnvironmentDefinition url="environment.xml"/> <EnvironmentDefinition url="/environment.xml"/>
<TypeDefinition url="typedefinition.xml"/> <TypeDefinition url="/typedefinition.xml"/>
<AppVariables url="appvariables.xml"/> <AppVariables url="/appvariables.xml"/>
<AppInfos> <AppInfos>
<AppInfo url="Application_Desktop.xadl"/> <AppInfo url="/Application_Desktop.xadl"/>
</AppInfos> </AppInfos>
</Project> </Project>

View File

@ -41,6 +41,8 @@
<Component type="JavaScript" id="DateField" classname="nexacro.DateField"/> <Component type="JavaScript" id="DateField" classname="nexacro.DateField"/>
<Component type="JavaScript" id="DateRangePicker" classname="nexacro.DateRangePicker"/> <Component type="JavaScript" id="DateRangePicker" classname="nexacro.DateRangePicker"/>
<Component type="JavaScript" id="PopupDateRangePicker" classname="nexacro.PopupDateRangePicker"/> <Component type="JavaScript" id="PopupDateRangePicker" classname="nexacro.PopupDateRangePicker"/>
<Component type="JavaScript" id="View" classname="nexacro.View"/>
<Component type="JavaScript" id="FileUpload" classname="nexacro.FileUpload"/>
</Components> </Components>
<Services> <Services>
<Service prefixid="theme" type="resource" url="./_resource_/_theme_/" version="0" cachelevel="session" include_subdir="true"/> <Service prefixid="theme" type="resource" url="./_resource_/_theme_/" version="0" cachelevel="session" include_subdir="true"/>
@ -50,8 +52,8 @@
<Service prefixid="font" type="resource" url="./_resource_/_font_/" version="0" cachelevel="session" include_subdir="false"/> <Service prefixid="font" type="resource" url="./_resource_/_font_/" version="0" cachelevel="session" include_subdir="false"/>
<Service prefixid="stringrc" type="resource" url="./_resource_/_stringrc_/" version="0" cachelevel="session" include_subdir="false"/> <Service prefixid="stringrc" type="resource" url="./_resource_/_stringrc_/" version="0" cachelevel="session" include_subdir="false"/>
<Service prefixid="extlib" type="resource" url="./_extlib_/" version="0" cachelevel="session" include_subdir="true"/> <Service prefixid="extlib" type="resource" url="./_extlib_/" version="0" cachelevel="session" include_subdir="true"/>
<Service prefixid="Base" type="form" url="./Base/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/>
<Service prefixid="FrameBase" type="form" url="./FrameBase/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/> <Service prefixid="FrameBase" type="form" url="./FrameBase/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/>
<Service prefixid="Forms" type="form" url="./forms/" version="0" cachelevel="session" include_subdir="false" communicationversion="0"/>
</Services> </Services>
<Protocols/> <Protocols/>
<DeviceAdaptors/> <DeviceAdaptors/>

View File

@ -10,8 +10,9 @@ const previewDir = path.join(repoRoot, "client", "nexacro-deploy");
const UTF8_BOM = "\uFEFF"; const UTF8_BOM = "\uFEFF";
const FRAME_TOP_HEIGHT = 56; const FRAME_TOP_HEIGHT = 56;
const FRAME_LEFT_WIDTH = 240; const FRAME_LEFT_WIDTH = 240;
const BASE_DIR = "Base"; const FORMS_DIR = "forms";
const FRAMEBASE_DIR = "FrameBase"; const FRAMEBASE_DIR = "FrameBase";
const LIB_DIR = "lib";
function readYaml(filePath) { function readYaml(filePath) {
return yaml.load(fs.readFileSync(filePath, "utf8")); return yaml.load(fs.readFileSync(filePath, "utf8"));
@ -61,7 +62,7 @@ function cleanGeneratedOutput(baseOutputDir, projectName) {
".DS_Store" ".DS_Store"
].forEach((file) => removePathIfExists(path.join(baseOutputDir, file))); ].forEach((file) => removePathIfExists(path.join(baseOutputDir, file)));
["forms", BASE_DIR, "frame", FRAMEBASE_DIR, "lib"].forEach((dir) => ["forms", FORMS_DIR, "frame", FRAMEBASE_DIR, "lib", LIB_DIR].forEach((dir) =>
removePathIfExists(path.join(baseOutputDir, dir)) removePathIfExists(path.join(baseOutputDir, dir))
); );
} }
@ -103,7 +104,6 @@ function toXfdlDataset(dataset) {
} }
function componentTag(component, index = 0) { function componentTag(component, index = 0) {
const componentType = component.type === "FileUpload" ? "Edit" : component.type;
const attrs = [ const attrs = [
`id="${component.id}"`, `id="${component.id}"`,
`taborder="${index}"`, `taborder="${index}"`,
@ -116,21 +116,22 @@ function componentTag(component, index = 0) {
if (component.text) { if (component.text) {
attrs.push(`text="${escapeXml(component.text)}"`); attrs.push(`text="${escapeXml(component.text)}"`);
} }
if (component.prompt && ["Edit", "MaskEdit", "TextArea"].includes(componentType)) { if (component.prompt && ["Edit", "MaskEdit", "TextArea"].includes(component.type)) {
attrs.push(`displaynulltext="${escapeXml(component.prompt)}"`); attrs.push(`displaynulltext="${escapeXml(component.prompt)}"`);
} }
if (componentType === "Edit" && component.id.toLowerCase().includes("password")) { if (component.bind && ["Edit", "MaskEdit", "TextArea"].includes(component.type)) {
const [, columnId] = component.bind.split(".");
attrs.push(`value="bind:${columnId}"`);
}
if (component.type === "Edit" && component.id.toLowerCase().includes("password")) {
attrs.push('password="true"'); attrs.push('password="true"');
} }
if (componentType === "Combo") { if (component.type === "Combo") {
attrs.push('codecolumn="code"'); attrs.push('codecolumn="code"');
attrs.push('datacolumn="label"'); attrs.push('datacolumn="label"');
} }
if (component.type === "FileUpload") {
attrs.push('readonly="true"');
}
return ` <${componentType} ${attrs.join(" ")}/>`; return ` <${component.type} ${attrs.join(" ")}/>`;
} }
function gridFormats(grid) { function gridFormats(grid) {
@ -184,21 +185,11 @@ function buildFormScript(form) {
.trim(); .trim();
} }
function renderMessageStatics(form, layout) {
return (form.messages || [])
.map(
(message, index) =>
` <Static id="staMessage${index}" taborder="${900 + index}" left="36" top="${Math.max(layout.height - 76 + index * 22, 36)}" width="${Math.max(layout.width - 72, 300)}" height="20" text="${escapeXml(message.text)}"/>`
)
.join("\n");
}
function renderXfdl(form, appSpec) { function renderXfdl(form, appSpec) {
const datasets = (form.datasets || []).map(toXfdlDataset).join("\n"); const datasets = (form.datasets || []).map(toXfdlDataset).join("\n");
const components = (form.components || []).map((component, index) => componentTag(component, index)).join("\n"); const components = (form.components || []).map((component, index) => componentTag(component, index)).join("\n");
const grids = (form.grids || []).map(gridTag).join("\n"); const grids = (form.grids || []).map(gridTag).join("\n");
const layout = form.layout || appSpec.layout; const layout = form.layout || appSpec.layout;
const messages = renderMessageStatics(form, layout);
return [ return [
'<?xml version="1.0" encoding="utf-8"?>', '<?xml version="1.0" encoding="utf-8"?>',
@ -214,7 +205,6 @@ function renderXfdl(form, appSpec) {
" ]]></Script>", " ]]></Script>",
components, components,
grids, grids,
messages,
" <Layouts>", " <Layouts>",
` <Layout width="${layout.width}" height="${layout.height}" screenid="Desktop_screen"/>`, ` <Layout width="${layout.width}" height="${layout.height}" screenid="Desktop_screen"/>`,
" </Layouts>", " </Layouts>",
@ -271,7 +261,7 @@ function renderWorkFrame(appSpec, defaultFormId) {
'<?xml version="1.0" encoding="utf-8"?>', '<?xml version="1.0" encoding="utf-8"?>',
'<FDL version="2.1">', '<FDL version="2.1">',
` <Form id="Form_Work" width="${workWidth}" height="${workHeight}" titletext="Form_Work">`, ` <Form id="Form_Work" width="${workWidth}" height="${workHeight}" titletext="Form_Work">`,
` <Div id="divWork" taborder="0" left="0" top="0" width="${workWidth}" height="${workHeight}" url="Base::${defaultFormId}.xfdl"/>`, ` <Div id="divWork" taborder="0" left="0" top="0" width="${workWidth}" height="${workHeight}" url="Forms::${defaultFormId}.xfdl"/>`,
" <Layouts>", " <Layouts>",
` <Layout width="${workWidth}" height="${workHeight}" screenid="Desktop_screen"/>`, ` <Layout width="${workWidth}" height="${workHeight}" screenid="Desktop_screen"/>`,
" </Layouts>", " </Layouts>",
@ -284,9 +274,9 @@ function renderWorkFrame(appSpec, defaultFormId) {
function generateProjectFiles(appSpec, forms, baseOutputDir = outputDir) { function generateProjectFiles(appSpec, forms, baseOutputDir = outputDir) {
cleanGeneratedOutput(baseOutputDir, appSpec.projectName); cleanGeneratedOutput(baseOutputDir, appSpec.projectName);
ensureDir(baseOutputDir); ensureDir(baseOutputDir);
ensureDir(path.join(baseOutputDir, BASE_DIR)); ensureDir(path.join(baseOutputDir, FORMS_DIR));
ensureDir(path.join(baseOutputDir, FRAMEBASE_DIR)); ensureDir(path.join(baseOutputDir, FRAMEBASE_DIR));
ensureDir(path.join(baseOutputDir, "_extlib_")); ensureDir(path.join(baseOutputDir, LIB_DIR));
writeGeneratedFile( writeGeneratedFile(
path.join(baseOutputDir, `${appSpec.projectName}.xprj`), path.join(baseOutputDir, `${appSpec.projectName}.xprj`),
@ -312,6 +302,9 @@ function generateProjectFiles(appSpec, forms, baseOutputDir = outputDir) {
); );
writeGeneratedFile(path.join(baseOutputDir, "typedefinition.xml"), readTemplate("typedefinition.xml.tpl")); writeGeneratedFile(path.join(baseOutputDir, "typedefinition.xml"), readTemplate("typedefinition.xml.tpl"));
writeGeneratedFile(path.join(baseOutputDir, "appvariables.xml"), readTemplate("appvariables.xml.tpl"), { bom: false }); writeGeneratedFile(path.join(baseOutputDir, "appvariables.xml"), readTemplate("appvariables.xml.tpl"), { bom: false });
writeGeneratedFile(path.join(baseOutputDir, LIB_DIR, "common.xjs"), readTemplate(path.join("common", "common.xjs.tpl")), {
bom: false
});
writeGeneratedFile(path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Top.xfdl"), renderTopFrame(appSpec)); writeGeneratedFile(path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Top.xfdl"), renderTopFrame(appSpec));
writeGeneratedFile(path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Left.xfdl"), renderLeftFrame(forms)); writeGeneratedFile(path.join(baseOutputDir, FRAMEBASE_DIR, "Form_Left.xfdl"), renderLeftFrame(forms));
const defaultForm = forms.find((form) => form.route === "login") || forms[0]; const defaultForm = forms.find((form) => form.route === "login") || forms[0];
@ -321,7 +314,7 @@ function generateProjectFiles(appSpec, forms, baseOutputDir = outputDir) {
); );
forms.forEach((form) => { forms.forEach((form) => {
writeGeneratedFile(path.join(baseOutputDir, BASE_DIR, `${form.formId}.xfdl`), renderXfdl(form, appSpec)); writeGeneratedFile(path.join(baseOutputDir, FORMS_DIR, `${form.formId}.xfdl`), renderXfdl(form, appSpec));
}); });
} }

View File

@ -23,7 +23,7 @@ test("project generator writes required Nexacro files", () => {
assert.ok(fs.existsSync(path.join(tempDir, "environment.xml"))); 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, "typedefinition.xml")));
assert.ok(fs.existsSync(path.join(tempDir, "appvariables.xml"))); assert.ok(fs.existsSync(path.join(tempDir, "appvariables.xml")));
assert.ok(fs.existsSync(path.join(tempDir, "Base", "frmLogin.xfdl"))); assert.ok(fs.existsSync(path.join(tempDir, "forms", "frmLogin.xfdl")));
assert.ok(fs.existsSync(path.join(tempDir, "FrameBase", "Form_Work.xfdl"))); assert.ok(fs.existsSync(path.join(tempDir, "FrameBase", "Form_Work.xfdl")));
assert.ok(fs.readFileSync(path.join(tempDir, "HanwhaNexacroDemo.xprj"), "utf8").includes("Application_Desktop.xadl")); assert.ok(fs.readFileSync(path.join(tempDir, "HanwhaNexacroDemo.xprj"), "utf8").includes("Application_Desktop.xadl"));