diff --git a/client/nexacro-deploy/assets/app.js b/client/nexacro-deploy/assets/app.js index ada360e..fe05cfb 100644 --- a/client/nexacro-deploy/assets/app.js +++ b/client/nexacro-deploy/assets/app.js @@ -125,22 +125,36 @@ function table(columns, rows, options = {}) { const value = options.render ? options.render(column, row[column.id], row) : row[column.id]; return `${formatValue(value)}`; }) - .join("")}` + .join("")}${options.actions ? `${options.actions(row) || ""}` : ""}` ) .join("") - : `데이터가 없습니다.`; + : `데이터가 없습니다.`; return `
${columns .map((column) => ``) - .join("")}${body}
${column.text}
`; + .join("")}${options.actions ? "작업" : ""}${body}`; } function pill(value) { return `${value}`; } +function hasRole(requiredRole) { + const roleOrder = { + PUBLIC: 0, + VIEWER: 10, + OPERATOR: 20, + ADMIN: 30 + }; + return (roleOrder[state.session?.roleCode] || 0) >= (roleOrder[requiredRole] || 0); +} + function isAdmin() { - return state.session?.roleCode === "ADMIN"; + return hasRole("ADMIN"); +} + +function isOperator() { + return hasRole("OPERATOR"); } function cloneRows(rows) { @@ -418,7 +432,7 @@ function renderUploads() {
- + 오류 샘플 정상 TB @@ -445,6 +459,9 @@ function renderUploads() { return pill(value); } return value; + }, + actions(row) { + return ``; } } )} @@ -791,6 +808,9 @@ function bindEvents() { const uploadButton = document.querySelector("[data-action='upload']"); if (uploadButton) { uploadButton.addEventListener("click", async () => { + if (!isOperator()) { + return; + } const templateCode = document.getElementById("upload-template").value; const fiscalPeriod = document.getElementById("upload-period").value; const file = document.getElementById("upload-file").files[0]; @@ -816,6 +836,22 @@ function bindEvents() { }); } + 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']"); if (runButton) { runButton.addEventListener("click", async () => { diff --git a/client/nexacro-deploy/assets/styles.css b/client/nexacro-deploy/assets/styles.css index d1f4b24..5088ee5 100644 --- a/client/nexacro-deploy/assets/styles.css +++ b/client/nexacro-deploy/assets/styles.css @@ -270,13 +270,17 @@ th { font-weight: 700; } +.actions-cell { + white-space: nowrap; + width: 88px; +} + .master-table input { min-width: 110px; padding: 10px 12px; } .master-table td.actions-cell { - white-space: nowrap; width: 88px; } diff --git a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadController.java b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadController.java index bf1a12a..bcf117d 100644 --- a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadController.java +++ b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadController.java @@ -9,6 +9,7 @@ import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -46,6 +47,12 @@ public class UploadController { return uploadService.loadIssues(batchId); } + @DeleteMapping("/{batchId}") + public Map delete(@PathVariable Long batchId, HttpSession session) { + authService.requireRole(session, "OPERATOR"); + return uploadService.deleteUploadBatch(batchId); + } + @GetMapping("/templates/{templateCode}/download") public ResponseEntity templateDownload(@PathVariable String templateCode, HttpSession session) { authService.requireRole(session, "VIEWER"); @@ -55,5 +62,18 @@ public class UploadController { .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + templateCode + "-template.xlsx\"") .body(resource); } -} + @GetMapping("/samples/{sampleCode}/download") + public ResponseEntity 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); + } +} diff --git a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadMapper.java b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadMapper.java index 3140630..9d6db65 100644 --- a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadMapper.java +++ b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadMapper.java @@ -2,6 +2,7 @@ package com.hanwha.nexacrodemo.upload; import java.util.List; import java.util.Map; +import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Options; @@ -154,5 +155,22 @@ public interface UploadMapper { order by ur.id """) List> 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); +} diff --git a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadService.java b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadService.java index 6b9570e..652f677 100644 --- a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadService.java +++ b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/UploadService.java @@ -26,6 +26,7 @@ import org.apache.poi.ss.usermodel.WorkbookFactory; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @Service @@ -133,10 +134,35 @@ public class UploadService { return MapKeyUtils.camelizeList(uploadMapper.listValidationIssuesByBatchId(batchId)); } + @Transactional + public Map deleteUploadBatch(Long batchId) { + Map 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 payload = new LinkedHashMap<>(); + payload.put("ok", true); + payload.put("batchId", batchId); + payload.put("message", "업로드 이력이 삭제되었습니다."); + return payload; + } + public ByteArrayResource downloadTemplate(String templateCode) { return new ByteArrayResource(workbookTemplateService.createTemplate(templateCode)); } + public ByteArrayResource downloadSample(String sampleCode, String fiscalPeriod) { + return new ByteArrayResource(workbookTemplateService.createSample(sampleCode, fiscalPeriod)); + } + private void parseDataRows( String templateCode, String fiscalPeriod, diff --git a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/WorkbookTemplateService.java b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/WorkbookTemplateService.java index 4a5a6f7..d3f9aed 100644 --- a/server/api/src/main/java/com/hanwha/nexacrodemo/upload/WorkbookTemplateService.java +++ b/server/api/src/main/java/com/hanwha/nexacrodemo/upload/WorkbookTemplateService.java @@ -5,6 +5,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.xssf.usermodel.XSSFSheet; import org.apache.poi.xssf.usermodel.XSSFWorkbook; @@ -14,6 +15,8 @@ import org.springframework.stereotype.Service; @Service public class WorkbookTemplateService { + private static final Pattern PERIOD_PATTERN = Pattern.compile("^\\d{4}-\\d{2}$"); + private static final Map> HEADERS = Map.of( "trial-balance", List.of("fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount"), "forecast", List.of("fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue") @@ -28,6 +31,34 @@ public class WorkbookTemplateService { } 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> rows) { try (XSSFWorkbook workbook = new XSSFWorkbook()) { XSSFSheet metaSheet = workbook.createSheet("META"); Row metaHeader = metaSheet.createRow(0); @@ -55,4 +86,3 @@ public class WorkbookTemplateService { } } } - diff --git a/server/api/src/test/java/com/hanwha/nexacrodemo/upload/UploadValidationIntegrationTest.java b/server/api/src/test/java/com/hanwha/nexacrodemo/upload/UploadValidationIntegrationTest.java index e90d248..59be133 100644 --- a/server/api/src/test/java/com/hanwha/nexacrodemo/upload/UploadValidationIntegrationTest.java +++ b/server/api/src/test/java/com/hanwha/nexacrodemo/upload/UploadValidationIntegrationTest.java @@ -1,5 +1,6 @@ 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.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -52,6 +53,64 @@ class UploadValidationIntegrationTest { .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()); + } + private MockHttpSession login(String username, String password) throws Exception { MvcResult result = mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) diff --git a/templates/nexacro/preview-app.js.tpl b/templates/nexacro/preview-app.js.tpl index 9ce06a1..5a6f9d2 100644 --- a/templates/nexacro/preview-app.js.tpl +++ b/templates/nexacro/preview-app.js.tpl @@ -57,22 +57,36 @@ function table(columns, rows, options = {}) { const value = options.render ? options.render(column, row[column.id], row) : row[column.id]; return `${formatValue(value)}`; }) - .join("")}` + .join("")}${options.actions ? `${options.actions(row) || ""}` : ""}` ) .join("") - : `데이터가 없습니다.`; + : `데이터가 없습니다.`; return `
${columns .map((column) => ``) - .join("")}${body}
${column.text}
`; + .join("")}${options.actions ? "작업" : ""}${body}
`; } function pill(value) { return `${value}`; } +function hasRole(requiredRole) { + const roleOrder = { + PUBLIC: 0, + VIEWER: 10, + OPERATOR: 20, + ADMIN: 30 + }; + return (roleOrder[state.session?.roleCode] || 0) >= (roleOrder[requiredRole] || 0); +} + function isAdmin() { - return state.session?.roleCode === "ADMIN"; + return hasRole("ADMIN"); +} + +function isOperator() { + return hasRole("OPERATOR"); } function cloneRows(rows) { @@ -350,11 +364,11 @@ function renderUploads() {
- + - 오류 샘플 - 정상 TB - 정상 Forecast + + +
@@ -377,6 +391,9 @@ function renderUploads() { return pill(value); } return value; + }, + actions(row) { + return ``; } } )} @@ -519,14 +536,6 @@ function shellContent(content) {
-
-
- -
${content}
@@ -723,6 +732,9 @@ function bindEvents() { const uploadButton = document.querySelector("[data-action='upload']"); if (uploadButton) { uploadButton.addEventListener("click", async () => { + if (!isOperator()) { + return; + } const templateCode = document.getElementById("upload-template").value; const fiscalPeriod = document.getElementById("upload-period").value; const file = document.getElementById("upload-file").files[0]; @@ -748,6 +760,22 @@ function bindEvents() { }); } + 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']"); if (runButton) { runButton.addEventListener("click", async () => { diff --git a/templates/nexacro/preview.css.tpl b/templates/nexacro/preview.css.tpl index d1f4b24..5088ee5 100644 --- a/templates/nexacro/preview.css.tpl +++ b/templates/nexacro/preview.css.tpl @@ -270,13 +270,17 @@ th { font-weight: 700; } +.actions-cell { + white-space: nowrap; + width: 88px; +} + .master-table input { min-width: 110px; padding: 10px 12px; } .master-table td.actions-cell { - white-space: nowrap; width: 88px; }