Implement upload batch deletion and role-based access control

- Added functionality to delete upload batches and related validation issues, accessible only to users with the 'OPERATOR' role.
- Updated the UploadController to handle DELETE requests for batch deletions.
- Enhanced the UploadService to manage the deletion process, ensuring related data is also removed.
- Modified the frontend to disable upload and delete buttons based on user roles, improving security and user experience.
- Added integration tests to verify that only operators can delete upload batches and that validation issues are removed accordingly.
This commit is contained in:
DongHeon Jang 2026-04-17 11:13:47 +09:00
parent d964b65f0a
commit 087a274f47
9 changed files with 251 additions and 26 deletions

View File

@ -125,22 +125,36 @@ function table(columns, rows, options = {}) {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return `<td>${formatValue(value)}</td>`;
})
.join("")}</tr>`
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>`
)
.join("")
: `<tr><td colspan="${columns.length}">데이터가 없습니다.</td></tr>`;
: `<tr><td colspan="${columns.length + (options.actions ? 1 : 0)}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`)
.join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
.join("")}${options.actions ? "<th>작업</th>" : ""}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function pill(value) {
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() {
return state.session?.roleCode === "ADMIN";
return hasRole("ADMIN");
}
function isOperator() {
return hasRole("OPERATOR");
}
function cloneRows(rows) {
@ -418,7 +432,7 @@ function renderUploads() {
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></div>
<div class="row"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions">
<button data-action="upload">파일 업로드</button>
<button data-action="upload" ${isOperator() ? "" : "disabled"}>파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
@ -445,6 +459,9 @@ function renderUploads() {
return pill(value);
}
return value;
},
actions(row) {
return `<button class="danger small" data-action="delete-upload" data-batch-id="${row.id}" ${isOperator() ? "" : "disabled"}>삭제</button>`;
}
}
)}
@ -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 () => {

View File

@ -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;
}

View File

@ -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<String, Object> delete(@PathVariable Long batchId, HttpSession session) {
authService.requireRole(session, "OPERATOR");
return uploadService.deleteUploadBatch(batchId);
}
@GetMapping("/templates/{templateCode}/download")
public ResponseEntity<ByteArrayResource> 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<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,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<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,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<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) {
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,

View File

@ -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<String, List<String>> HEADERS = Map.of(
"trial-balance", List.of("fiscalPeriod", "entityCode", "accountCode", "partnerEntityCode", "currencyCode", "debitAmount", "creditAmount"),
"forecast", List.of("fiscalPeriod", "entityCode", "accountCode", "currencyCode", "scenarioCode", "amountValue")
@ -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<List<String>> rows) {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
XSSFSheet metaSheet = workbook.createSheet("META");
Row metaHeader = metaSheet.createRow(0);
@ -55,4 +86,3 @@ public class WorkbookTemplateService {
}
}
}

View File

@ -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)

View File

@ -57,22 +57,36 @@ function table(columns, rows, options = {}) {
const value = options.render ? options.render(column, row[column.id], row) : row[column.id];
return `<td>${formatValue(value)}</td>`;
})
.join("")}</tr>`
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>`
)
.join("")
: `<tr><td colspan="${columns.length}">데이터가 없습니다.</td></tr>`;
: `<tr><td colspan="${columns.length + (options.actions ? 1 : 0)}">데이터가 없습니다.</td></tr>`;
return `<div class="table-wrap"><table><thead><tr>${columns
.map((column) => `<th>${column.text}</th>`)
.join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
.join("")}${options.actions ? "<th>작업</th>" : ""}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function pill(value) {
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() {
return state.session?.roleCode === "ADMIN";
return hasRole("ADMIN");
}
function isOperator() {
return hasRole("OPERATOR");
}
function cloneRows(rows) {
@ -350,11 +364,11 @@ function renderUploads() {
<div class="row"><label>회계기간</label><input id="upload-period" value="2026-03" /></div>
<div class="row"><label>파일 선택</label><input id="upload-file" type="file" /></div>
<div class="row actions">
<button data-action="upload">파일 업로드</button>
<button data-action="upload" ${isOperator() ? "" : "disabled"}>파일 업로드</button>
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
<a class="ghost" href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-invalid">오류 샘플</button>
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button>
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 Forecast</button>
</div>
</div>
<div class="panel">
@ -377,6 +391,9 @@ function renderUploads() {
return pill(value);
}
return value;
},
actions(row) {
return `<button class="danger small" data-action="delete-upload" data-batch-id="${row.id}" ${isOperator() ? "" : "disabled"}>삭제</button>`;
}
}
)}
@ -519,14 +536,6 @@ function shellContent(content) {
</div>
</aside>
<main class="main">
<div class="topbar">
<div></div>
<div class="footer-links">
<a href="/sample-data/trial-balance-invalid.xlsx" download>오류 샘플</a>
<a href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
<a href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
</div>
</div>
${content}
</main>
</div>
@ -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 () => {

View File

@ -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;
}