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:
parent
d964b65f0a
commit
087a274f47
|
|
@ -125,22 +125,36 @@ 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("")}</tr>`
|
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>`
|
||||||
)
|
)
|
||||||
.join("")
|
.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
|
return `<div class="table-wrap"><table><thead><tr>${columns
|
||||||
.map((column) => `<th>${column.text}</th>`)
|
.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) {
|
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 state.session?.roleCode === "ADMIN";
|
return hasRole("ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOperator() {
|
||||||
|
return hasRole("OPERATOR");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneRows(rows) {
|
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-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">파일 업로드</button>
|
<button data-action="upload" ${isOperator() ? "" : "disabled"}>파일 업로드</button>
|
||||||
<button class="secondary" data-action="reload-uploads">내역 새로고침</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-invalid.xlsx" download>오류 샘플</a>
|
||||||
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
|
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
|
||||||
|
|
@ -445,6 +459,9 @@ 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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
|
@ -791,6 +808,9 @@ 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];
|
||||||
|
|
@ -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']");
|
const runButton = document.querySelector("[data-action='request-run']");
|
||||||
if (runButton) {
|
if (runButton) {
|
||||||
runButton.addEventListener("click", async () => {
|
runButton.addEventListener("click", async () => {
|
||||||
|
|
|
||||||
|
|
@ -270,13 +270,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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;
|
||||||
|
|
@ -46,6 +47,12 @@ 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");
|
||||||
|
|
@ -55,5 +62,18 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ 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;
|
||||||
|
|
@ -154,5 +155,22 @@ 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ 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
|
||||||
|
|
@ -133,10 +134,35 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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;
|
||||||
|
|
@ -14,6 +15,8 @@ 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")
|
||||||
|
|
@ -28,6 +31,34 @@ 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);
|
||||||
|
|
@ -55,4 +86,3 @@ public class WorkbookTemplateService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -52,6 +53,64 @@ 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());
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -57,22 +57,36 @@ 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("")}</tr>`
|
.join("")}${options.actions ? `<td class="actions-cell">${options.actions(row) || ""}</td>` : ""}</tr>`
|
||||||
)
|
)
|
||||||
.join("")
|
.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
|
return `<div class="table-wrap"><table><thead><tr>${columns
|
||||||
.map((column) => `<th>${column.text}</th>`)
|
.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) {
|
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 state.session?.roleCode === "ADMIN";
|
return hasRole("ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOperator() {
|
||||||
|
return hasRole("OPERATOR");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneRows(rows) {
|
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-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">파일 업로드</button>
|
<button data-action="upload" ${isOperator() ? "" : "disabled"}>파일 업로드</button>
|
||||||
<button class="secondary" data-action="reload-uploads">내역 새로고침</button>
|
<button class="secondary" data-action="reload-uploads">내역 새로고침</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-invalid">오류 샘플</button>
|
||||||
<a class="ghost" href="/sample-data/trial-balance-valid.xlsx" download>정상 TB</a>
|
<button class="ghost" data-action="download-sample" data-sample-code="trial-balance-valid">정상 TB</button>
|
||||||
<a class="ghost" href="/sample-data/forecast-valid.xlsx" download>정상 Forecast</a>
|
<button class="ghost" data-action="download-sample" data-sample-code="forecast-valid">정상 Forecast</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
|
|
@ -377,6 +391,9 @@ 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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
|
@ -519,14 +536,6 @@ 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>
|
||||||
|
|
@ -723,6 +732,9 @@ 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];
|
||||||
|
|
@ -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']");
|
const runButton = document.querySelector("[data-action='request-run']");
|
||||||
if (runButton) {
|
if (runButton) {
|
||||||
runButton.addEventListener("click", async () => {
|
runButton.addEventListener("click", async () => {
|
||||||
|
|
|
||||||
|
|
@ -270,13 +270,17 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue