From d38d14ed8ecaba66b842445e5f5dbb6405d1d224 Mon Sep 17 00:00:00 2001 From: DongHeon Jang Date: Sun, 12 Apr 2026 11:39:43 +0900 Subject: [PATCH] Initial Hanwha Nexacro demo scaffold --- .gitignore | 5 + README.md | 63 + client/nexacro-deploy/assets/app.js | 555 +++++++++ client/nexacro-deploy/assets/forms.js | 67 ++ client/nexacro-deploy/assets/styles.css | 264 ++++ client/nexacro-deploy/index.html | 13 + client/nexacro-src/HanwhaNexacroDemo.xprj | 15 + client/nexacro-src/application.xadl | 7 + client/nexacro-src/appvariables.xml | 6 + client/nexacro-src/environment.xml | 9 + .../nexacro-src/forms/frmConsolidation.xfdl | 61 + client/nexacro-src/forms/frmLogin.xfdl | 52 + client/nexacro-src/forms/frmMasterData.xfdl | 84 ++ client/nexacro-src/forms/frmReportsOps.xfdl | 66 + .../forms/frmUploadValidation.xfdl | 76 ++ client/nexacro-src/frame/MainFrame.xfdl | 7 + client/nexacro-src/lib/common.xjs | 15 + client/nexacro-src/typedefinition.xml | 13 + docker-compose.yml | 104 ++ docs/admin-guide.md | 22 + docs/nexacro-windows-build-guide.md | 22 + docs/runbook.md | 22 + docs/spec-dsl.md | 25 + docs/troubleshooting.md | 20 + docs/vendor-substitution.md | 22 + ops/nginx/default.conf | 37 + sample-data/forecast-template.xlsx | Bin 0 -> 5396 bytes sample-data/forecast-valid.xlsx | Bin 0 -> 5502 bytes sample-data/trial-balance-invalid.xlsx | Bin 0 -> 5516 bytes sample-data/trial-balance-template.xlsx | Bin 0 -> 5409 bytes sample-data/trial-balance-valid.xlsx | Bin 0 -> 5592 bytes scripts/generate_sample_uploads.py | 69 ++ server/api/Dockerfile | 17 + server/api/build.gradle | 44 + server/api/settings.gradle | 2 + .../HanwhaNexacroDemoApplication.java | 17 + .../nexacrodemo/auth/AuthController.java | 38 + .../hanwha/nexacrodemo/auth/AuthService.java | 70 ++ .../auth/ConsolidationRequest.java | 29 + .../hanwha/nexacrodemo/auth/LoginRequest.java | 29 + .../hanwha/nexacrodemo/auth/SessionUser.java | 44 + .../hanwha/nexacrodemo/auth/UserAccount.java | 60 + .../hanwha/nexacrodemo/auth/UserMapper.java | 26 + .../nexacrodemo/batch/BatchScheduler.java | 26 + .../nexacrodemo/common/ApiException.java | 18 + .../common/GlobalExceptionHandler.java | 39 + .../hanwha/nexacrodemo/common/HashUtils.java | 31 + .../nexacrodemo/common/MapKeyUtils.java | 47 + .../hanwha/nexacrodemo/common/TxResponse.java | 51 + .../nexacrodemo/config/AuthInterceptor.java | 28 + .../hanwha/nexacrodemo/config/WebConfig.java | 21 + .../ConsolidationController.java | 53 + .../consolidation/ConsolidationMapper.java | 138 +++ .../consolidation/ConsolidationPayload.java | 32 + .../ConsolidationRunCommand.java | 51 + .../consolidation/ConsolidationService.java | 190 +++ .../nexacrodemo/master/MasterDataMapper.java | 53 + .../nexacrodemo/master/MasterDataService.java | 74 ++ .../minio/LocalObjectStorageService.java | 59 + .../minio/MinioObjectStorageService.java | 85 ++ .../minio/ObjectStorageService.java | 9 + .../nexacrodemo/minio/StoredObject.java | 21 + .../report/ReportArtifactCommand.java | 51 + .../nexacrodemo/report/ReportController.java | 29 + .../nexacrodemo/report/ReportMapper.java | 44 + .../nexacrodemo/report/ReportService.java | 153 +++ .../nexacrodemo/tx/TransactionController.java | 57 + .../nexacrodemo/upload/ParsedWorkbookRow.java | 107 ++ .../upload/UploadBatchCommand.java | 96 ++ .../nexacrodemo/upload/UploadController.java | 59 + .../nexacrodemo/upload/UploadMapper.java | 158 +++ .../nexacrodemo/upload/UploadService.java | 291 +++++ .../upload/ValidationIssueCommand.java | 51 + .../upload/WorkbookTemplateService.java | 58 + .../src/main/resources/application-batch.yml | 4 + server/api/src/main/resources/application.yml | 60 + .../resources/db/migration/V1__baseline.sql | 133 +++ .../resources/db/migration/V2__seed_data.sql | 33 + .../ConsolidationIntegrationTest.java | 94 ++ .../tx/TransactionControllerTest.java | 52 + .../upload/TestWorkbookFactory.java | 74 ++ .../UploadValidationIntegrationTest.java | 68 ++ .../src/test/resources/application-test.yml | 16 + spec/nexacro/app.yaml | 26 + spec/nexacro/consolidation.yaml | 72 ++ spec/nexacro/login.yaml | 61 + spec/nexacro/master-data.yaml | 89 ++ spec/nexacro/reports-ops.yaml | 63 + spec/nexacro/upload-validation.yaml | 107 ++ templates/nexacro/application.xadl.tpl | 7 + templates/nexacro/appvariables.xml.tpl | 6 + templates/nexacro/common/common.xjs.tpl | 15 + templates/nexacro/environment.xml.tpl | 9 + templates/nexacro/project.xml.tpl | 11 + templates/nexacro/typedefinition.xml.tpl | 13 + tools/nexacro-gen/index.js | 1058 +++++++++++++++++ tools/nexacro-gen/package-lock.json | 33 + tools/nexacro-gen/package.json | 14 + tools/nexacro-gen/test/generator.test.js | 47 + 99 files changed, 6442 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client/nexacro-deploy/assets/app.js create mode 100644 client/nexacro-deploy/assets/forms.js create mode 100644 client/nexacro-deploy/assets/styles.css create mode 100644 client/nexacro-deploy/index.html create mode 100644 client/nexacro-src/HanwhaNexacroDemo.xprj create mode 100644 client/nexacro-src/application.xadl create mode 100644 client/nexacro-src/appvariables.xml create mode 100644 client/nexacro-src/environment.xml create mode 100644 client/nexacro-src/forms/frmConsolidation.xfdl create mode 100644 client/nexacro-src/forms/frmLogin.xfdl create mode 100644 client/nexacro-src/forms/frmMasterData.xfdl create mode 100644 client/nexacro-src/forms/frmReportsOps.xfdl create mode 100644 client/nexacro-src/forms/frmUploadValidation.xfdl create mode 100644 client/nexacro-src/frame/MainFrame.xfdl create mode 100644 client/nexacro-src/lib/common.xjs create mode 100644 client/nexacro-src/typedefinition.xml create mode 100644 docker-compose.yml create mode 100644 docs/admin-guide.md create mode 100644 docs/nexacro-windows-build-guide.md create mode 100644 docs/runbook.md create mode 100644 docs/spec-dsl.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/vendor-substitution.md create mode 100644 ops/nginx/default.conf create mode 100644 sample-data/forecast-template.xlsx create mode 100644 sample-data/forecast-valid.xlsx create mode 100644 sample-data/trial-balance-invalid.xlsx create mode 100644 sample-data/trial-balance-template.xlsx create mode 100644 sample-data/trial-balance-valid.xlsx create mode 100644 scripts/generate_sample_uploads.py create mode 100644 server/api/Dockerfile create mode 100644 server/api/build.gradle create mode 100644 server/api/settings.gradle create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/HanwhaNexacroDemoApplication.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/AuthController.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/AuthService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/ConsolidationRequest.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/LoginRequest.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/SessionUser.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/UserAccount.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/auth/UserMapper.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/batch/BatchScheduler.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/common/ApiException.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/common/GlobalExceptionHandler.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/common/HashUtils.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/common/MapKeyUtils.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/common/TxResponse.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/config/AuthInterceptor.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/config/WebConfig.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/consolidation/ConsolidationController.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/consolidation/ConsolidationMapper.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/consolidation/ConsolidationPayload.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/consolidation/ConsolidationRunCommand.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/consolidation/ConsolidationService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/master/MasterDataMapper.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/master/MasterDataService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/minio/LocalObjectStorageService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/minio/MinioObjectStorageService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/minio/ObjectStorageService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/minio/StoredObject.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportArtifactCommand.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportController.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportMapper.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/report/ReportService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/tx/TransactionController.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/ParsedWorkbookRow.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadBatchCommand.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadController.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadMapper.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadService.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/ValidationIssueCommand.java create mode 100644 server/api/src/main/java/com/hanwha/nexacrodemo/upload/WorkbookTemplateService.java create mode 100644 server/api/src/main/resources/application-batch.yml create mode 100644 server/api/src/main/resources/application.yml create mode 100644 server/api/src/main/resources/db/migration/V1__baseline.sql create mode 100644 server/api/src/main/resources/db/migration/V2__seed_data.sql create mode 100644 server/api/src/test/java/com/hanwha/nexacrodemo/consolidation/ConsolidationIntegrationTest.java create mode 100644 server/api/src/test/java/com/hanwha/nexacrodemo/tx/TransactionControllerTest.java create mode 100644 server/api/src/test/java/com/hanwha/nexacrodemo/upload/TestWorkbookFactory.java create mode 100644 server/api/src/test/java/com/hanwha/nexacrodemo/upload/UploadValidationIntegrationTest.java create mode 100644 server/api/src/test/resources/application-test.yml create mode 100644 spec/nexacro/app.yaml create mode 100644 spec/nexacro/consolidation.yaml create mode 100644 spec/nexacro/login.yaml create mode 100644 spec/nexacro/master-data.yaml create mode 100644 spec/nexacro/reports-ops.yaml create mode 100644 spec/nexacro/upload-validation.yaml create mode 100644 templates/nexacro/application.xadl.tpl create mode 100644 templates/nexacro/appvariables.xml.tpl create mode 100644 templates/nexacro/common/common.xjs.tpl create mode 100644 templates/nexacro/environment.xml.tpl create mode 100644 templates/nexacro/project.xml.tpl create mode 100644 templates/nexacro/typedefinition.xml.tpl create mode 100644 tools/nexacro-gen/index.js create mode 100644 tools/nexacro-gen/package-lock.json create mode 100644 tools/nexacro-gen/package.json create mode 100644 tools/nexacro-gen/test/generator.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17fb329 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +tools/nexacro-gen/node_modules/ +server/api/.gradle/ +server/api/build/ +.DS_Store + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7adf83 --- /dev/null +++ b/README.md @@ -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는 생성된 소스를 열어 검수하고 웹 배포 산출물을 생성하는 용도로 사용한다. diff --git a/client/nexacro-deploy/assets/app.js b/client/nexacro-deploy/assets/app.js new file mode 100644 index 0000000..5ab0d2c --- /dev/null +++ b/client/nexacro-deploy/assets/app.js @@ -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) => `${columns + .map((column) => { + const value = options.render ? options.render(column, row[column.id], row) : row[column.id]; + return `${formatValue(value)}`; + }) + .join("")}` + ) + .join("") + : `데이터가 없습니다.`; + + return `
${columns + .map((column) => ``) + .join("")}${body}
${column.text}
`; +} + +function pill(value) { + return `${value}`; +} + +function renderNav() { + return window.HANWHA_FORMS.map((form) => { + const active = state.currentRoute === form.route ? "active" : ""; + const locked = form.authority !== "PUBLIC" && !state.session; + return ``; + }).join(""); +} + +function heroContent() { + const form = formMap.get(state.currentRoute); + const note = form?.messages?.[0]?.text || "Spec driven preview"; + return `

${form.title}

${note}

`; +} + +function renderLogin() { + return ` + ${heroContent()} +
+

세션 로그인

+
기본 계정: admin/operator/viewer / demo1234
+
+
+
+ +
+
+ `; +} + +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()} +
+
법인 수
${entities.length}
+
계정 수
${accounts.length}
+
환율 수
${fxRates.length}
+
지분율 수
${ownerships.length}
+
+
+
+

법인정보

+ ${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)} +
+
+

계정코드

+ ${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)} +
+
+

환율

+ ${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)} +
+
+

지분율

+ ${table([{ id: "parentEntityCode", text: "모법인" }, { id: "childEntityCode", text: "자법인" }, { id: "ownershipRatio", text: "지분율" }], ownerships)} +
+
+ `; +} + +function renderUploads() { + const datasets = state.uploads?.datasets || {}; + const batches = datasets.uploadBatches || []; + const issues = datasets.validationIssues || []; + + return ` + ${heroContent()} +
+

업로드

+
+ +
+
+
+
+ + + 오류 샘플 + 정상 TB + 정상 Forecast +
+
+
+

업로드 이력

+ ${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; + } + } + )} +
+
+

오류내역

+ ${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; + } + } + )} +
+ `; +} + +function renderConsolidation() { + const runs = state.runs?.datasets?.runs || []; + return ` + ${heroContent()} +
+

집계 실행

+
+
+ + +
+
+
+

집계 이력

+ ${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; + } + } + )} +
+ `; +} + +function renderReports() { + const datasets = state.reports?.datasets || {}; + const artifacts = datasets.artifacts || []; + const logs = datasets.jobLogs || []; + + return ` + ${heroContent()} +
+
산출물 수
${artifacts.length}
+
최근 로그 수
${logs.length}
+
세션 사용자
${state.session?.fullName || "-"}
+
+
+

리포트 산출물

+ ${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 `${value}`; + } + return value; + } + } + )} +
+
+

최근 배치 로그

+ ${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; + } + } + )} +
+ `; +} + +function shellContent(content) { + return ` +
+ +
+ + ${content} +
+
+ `; +} + +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 = "
정의되지 않은 화면입니다.
"; + } + + 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 = `

초기화 실패

${error.message}

`; +}); diff --git a/client/nexacro-deploy/assets/forms.js b/client/nexacro-deploy/assets/forms.js new file mode 100644 index 0000000..7a5c664 --- /dev/null +++ b/client/nexacro-deploy/assets/forms.js @@ -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 샘플을 재업로드합니다." + } + ] + } +]; diff --git a/client/nexacro-deploy/assets/styles.css b/client/nexacro-deploy/assets/styles.css new file mode 100644 index 0000000..a60246e --- /dev/null +++ b/client/nexacro-deploy/assets/styles.css @@ -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; } +} diff --git a/client/nexacro-deploy/index.html b/client/nexacro-deploy/index.html new file mode 100644 index 0000000..c38f575 --- /dev/null +++ b/client/nexacro-deploy/index.html @@ -0,0 +1,13 @@ + + + + + + Hanwha Nexacro Demo Preview + + + +
+ + + diff --git a/client/nexacro-src/HanwhaNexacroDemo.xprj b/client/nexacro-src/HanwhaNexacroDemo.xprj new file mode 100644 index 0000000..20ee3a1 --- /dev/null +++ b/client/nexacro-src/HanwhaNexacroDemo.xprj @@ -0,0 +1,15 @@ + + + + + + + +
+ + + + + + + diff --git a/client/nexacro-src/application.xadl b/client/nexacro-src/application.xadl new file mode 100644 index 0000000..4d6a603 --- /dev/null +++ b/client/nexacro-src/application.xadl @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/nexacro-src/appvariables.xml b/client/nexacro-src/appvariables.xml new file mode 100644 index 0000000..87cea71 --- /dev/null +++ b/client/nexacro-src/appvariables.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/nexacro-src/environment.xml b/client/nexacro-src/environment.xml new file mode 100644 index 0000000..e43e979 --- /dev/null +++ b/client/nexacro-src/environment.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/nexacro-src/forms/frmConsolidation.xfdl b/client/nexacro-src/forms/frmConsolidation.xfdl new file mode 100644 index 0000000..e1af332 --- /dev/null +++ b/client/nexacro-src/forms/frmConsolidation.xfdl @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + \`; + }).join(""); +} + +function heroContent() { + const form = formMap.get(state.currentRoute); + const note = form?.messages?.[0]?.text || "Spec driven preview"; + return \`

\${form.title}

\${note}

\`; +} + +function renderLogin() { + return \` + \${heroContent()} + + \`; +} + +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()} +
+
법인 수
\${entities.length}
+
계정 수
\${accounts.length}
+
환율 수
\${fxRates.length}
+
지분율 수
\${ownerships.length}
+
+
+
+

법인정보

+ \${table([{ id: "entityCode", text: "법인코드" }, { id: "entityName", text: "법인명" }, { id: "baseCurrency", text: "통화" }], entities)} +
+
+

계정코드

+ \${table([{ id: "accountCode", text: "계정" }, { id: "accountName", text: "계정명" }, { id: "accountCategory", text: "분류" }, { id: "internalTradeYn", text: "내부거래" }], accounts)} +
+
+

환율

+ \${table([{ id: "fiscalPeriod", text: "회계기간" }, { id: "currencyCode", text: "통화" }, { id: "rateToKrw", text: "환산율" }], fxRates)} +
+
+

지분율

+ \${table([{ id: "parentEntityCode", text: "모법인" }, { id: "childEntityCode", text: "자법인" }, { id: "ownershipRatio", text: "지분율" }], ownerships)} +
+
+ \`; +} + +function renderUploads() { + const datasets = state.uploads?.datasets || {}; + const batches = datasets.uploadBatches || []; + const issues = datasets.validationIssues || []; + + return \` + \${heroContent()} +
+
+

업로드 이력

+ \${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; + } + } + )} +
+
+

오류내역

+ \${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; + } + } + )} +
+ \`; +} + +function renderConsolidation() { + const runs = state.runs?.datasets?.runs || []; + return \` + \${heroContent()} +
+

집계 실행

+
+
+ + +
+
+
+

집계 이력

+ \${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; + } + } + )} +
+ \`; +} + +function renderReports() { + const datasets = state.reports?.datasets || {}; + const artifacts = datasets.artifacts || []; + const logs = datasets.jobLogs || []; + + return \` + \${heroContent()} +
+
산출물 수
\${artifacts.length}
+
최근 로그 수
\${logs.length}
+
세션 사용자
\${state.session?.fullName || "-"}
+
+
+

리포트 산출물

+ \${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 \`\${value}\`; + } + return value; + } + } + )} +
+
+

최근 배치 로그

+ \${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; + } + } + )} +
+ \`; +} + +function shellContent(content) { + return \` +
+ +
+ + \${content} +
+
+ \`; +} + +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 = "
정의되지 않은 화면입니다.
"; + } + + 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 = \`

초기화 실패

\${error.message}

\`; +}); +`; +} + +function generatePreview(appSpec, forms, basePreviewDir = previewDir) { + ensureDir(path.join(basePreviewDir, "assets")); + ensureDir(path.join(basePreviewDir, "sample-data")); + fs.writeFileSync(path.join(basePreviewDir, "index.html"), previewHtml(appSpec)); + fs.writeFileSync(path.join(basePreviewDir, "assets", "forms.js"), previewScript(forms)); + fs.writeFileSync(path.join(basePreviewDir, "assets", "styles.css"), previewCss()); + const appJs = `${fs.readFileSync(path.join(basePreviewDir, "assets", "forms.js"), "utf8")}\n${previewAppJs(appSpec)}`; + fs.writeFileSync(path.join(basePreviewDir, "assets", "app.js"), appJs); +} + +function generate() { + const { appSpec, forms } = loadSpecs(); + generateProjectFiles(appSpec, forms); + generatePreview(appSpec, forms); +} + +if (require.main === module) { + generate(); +} + +module.exports = { + loadSpecs, + generateProjectFiles, + generatePreview, + generate, + renderXfdl +}; + diff --git a/tools/nexacro-gen/package-lock.json b/tools/nexacro-gen/package-lock.json new file mode 100644 index 0000000..34374e0 --- /dev/null +++ b/tools/nexacro-gen/package-lock.json @@ -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" + } + } + } +} diff --git a/tools/nexacro-gen/package.json b/tools/nexacro-gen/package.json new file mode 100644 index 0000000..5ff0f2f --- /dev/null +++ b/tools/nexacro-gen/package.json @@ -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" + } +} + diff --git a/tools/nexacro-gen/test/generator.test.js b/tools/nexacro-gen/test/generator.test.js new file mode 100644 index 0000000..4f2e73d --- /dev/null +++ b/tools/nexacro-gen/test/generator.test.js @@ -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("")); + assert.ok(xfdl.includes("