feat: Excel batch registration (equipment + employees)

This commit is contained in:
pray4skylark
2026-06-05 11:22:13 +09:00
parent eb5734aad6
commit 8b2607ddb3
9 changed files with 797 additions and 131 deletions
@@ -2,6 +2,7 @@ package com.srsystem.controller;
import com.srsystem.config.jwt.CurrentUser;
import com.srsystem.dto.ApiResponse;
import com.srsystem.dto.BatchResult;
import com.srsystem.dto.ChangePasswordRequest;
import com.srsystem.dto.EmployeeSearchResult;
import com.srsystem.dto.EmployeeSummary;
@@ -14,6 +15,7 @@ import com.srsystem.entity.EquipmentAssignment;
import com.srsystem.entity.enums.DispatchStatus;
import com.srsystem.entity.enums.EmpStatus;
import com.srsystem.entity.enums.RoleCode;
import com.srsystem.util.ExcelParser;
import com.srsystem.repository.EquipmentAssignmentRepository;
import com.srsystem.service.DispatchHistoryService;
import com.srsystem.service.EmployeeService;
@@ -27,12 +29,16 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -250,4 +256,78 @@ public class EmployeeController {
employeeService.changePassword(currentUser.getEmpId(), request);
return ResponseEntity.ok(ApiResponse.ok("비밀번호가 변경되었습니다", null));
}
// ---- Batch ----
@Operation(summary = "직원 일괄 등록", description = "엑셀 파일로 직원을 일괄 등록합니다")
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("/batch")
@Transactional
public ResponseEntity<ApiResponse<BatchResult<Employee>>> batchCreate(@RequestParam("file") MultipartFile file) {
try {
ExcelParser.ParseResult<Employee> parsed = ExcelParser.parse(
file,
(row) -> {
Employee emp = new Employee();
emp.setLoginId(row.getOrDefault("로그인ID", ""));
emp.setName(row.getOrDefault("이름", ""));
emp.setEmail(row.getOrDefault("이메일", ""));
emp.setPhone(row.getOrDefault("전화번호", ""));
emp.setRoleCode(RoleCode.valueOf(row.getOrDefault("권한", "EMPLOYEE").toUpperCase()));
emp.setEmpStatus(EmpStatus.valueOf(row.getOrDefault("재직상태", "ACTIVE").toUpperCase()));
emp.setDispatchStatus(DispatchStatus.valueOf(row.getOrDefault("파견상태", "WAITING").toUpperCase()));
emp.setPassword("batch1234!");
if (row.containsKey("생년월일")) emp.setBirthDate(java.time.LocalDate.parse(row.get("생년월일")));
if (row.containsKey("학력")) emp.setEducation(row.get("학력"));
if (row.containsKey("학과")) emp.setMajor(row.get("학과"));
if (row.containsKey("입사일")) emp.setJoinedDate(java.time.LocalDate.parse(row.get("입사일")));
return emp;
},
List.of()
);
List<BatchResult.Failure> failures = new ArrayList<>();
List<Employee> success = new ArrayList<>();
for (var pf : parsed.failures()) {
failures.add(new BatchResult.Failure(pf.row(), pf.reason()));
}
for (int i = 0; i < parsed.data().size(); i++) {
try {
Employee emp = employeeService.create(parsed.data().get(i));
success.add(emp);
} catch (Exception e) {
failures.add(new BatchResult.Failure(i + 2, e.getMessage()));
}
}
BatchResult<Employee> result = new BatchResult<>(
parsed.data().size() + parsed.failures().size(),
success.size(), failures.size(), failures, success
);
return ResponseEntity.ok(ApiResponse.ok(
String.format("%d건 중 %d건 성공, %d건 실패", result.totalCount(), result.successCount(), result.failCount()),
result
));
} catch (Exception e) {
return ResponseEntity.badRequest().body(ApiResponse.error("엑셀 파싱 실패: " + e.getMessage()));
}
}
@Operation(summary = "직원 일괄 등록 템플릿 다운로드")
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/batch/template")
public ResponseEntity<byte[]> downloadTemplate() throws Exception {
String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학과", "입사일"};
Map<Integer, String[]> dropdowns = Map.of(
4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"},
5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"},
6, new String[]{"WAITING", "DISPATCHED"}
);
byte[] data = ExcelParser.generateTemplate(headers, "직원 일괄등록", dropdowns);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=employee_batch_template.xlsx")
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(data);
}
}
@@ -1,6 +1,7 @@
package com.srsystem.controller;
import com.srsystem.dto.ApiResponse;
import com.srsystem.dto.BatchResult;
import com.srsystem.dto.CreateEquipmentRequest;
import com.srsystem.dto.CreateMaintenanceRequest;
import com.srsystem.dto.EquipmentSummary;
@@ -11,6 +12,7 @@ import com.srsystem.entity.EquipmentMaintenance;
import com.srsystem.entity.enums.EquipmentStatus;
import com.srsystem.entity.enums.EquipmentType;
import com.srsystem.service.EquipmentService;
import com.srsystem.util.ExcelParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,11 +21,15 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -158,4 +164,75 @@ public class EquipmentController {
Equipment equipment = equipmentService.removeImage(equipmentId, imageIndex);
return ResponseEntity.ok(ApiResponse.ok("이미지가 삭제되었습니다", equipment));
}
// ---- Batch ----
@Operation(summary = "장비 일괄 등록", description = "엑셀 파일로 장비를 일괄 등록합니다")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@Transactional
@PostMapping("/batch")
public ResponseEntity<ApiResponse<BatchResult<Equipment>>> batchCreate(@RequestParam("file") MultipartFile file) {
try {
ExcelParser.ParseResult<CreateEquipmentRequest> parsed = ExcelParser.parse(
file,
(row) -> new CreateEquipmentRequest(
EquipmentType.valueOf(row.getOrDefault("장비유형", "LAPTOP").toUpperCase()),
row.getOrDefault("제조사", ""),
row.getOrDefault("모델명", ""),
row.getOrDefault("시리얼번호", ""),
row.getOrDefault("자산코드", ""),
row.getOrDefault("제원정보", ""),
EquipmentStatus.AVAILABLE,
row.containsKey("구매일") ? java.time.LocalDate.parse(row.get("구매일")) : java.time.LocalDate.now(),
null
),
List.of()
);
List<BatchResult.Failure> failures = new ArrayList<>();
List<Equipment> success = new ArrayList<>();
var parseFailures = parsed.failures();
for (var pf : parseFailures) {
failures.add(new BatchResult.Failure(pf.row(), pf.reason()));
}
for (int i = 0; i < parsed.data().size(); i++) {
try {
Equipment eq = equipmentService.createEquipment(parsed.data().get(i));
success.add(eq);
} catch (Exception e) {
failures.add(new BatchResult.Failure(i + 2, e.getMessage()));
}
}
BatchResult<Equipment> result = new BatchResult<>(
parsed.data().size() + parseFailures.size(),
success.size(),
failures.size(),
failures,
success
);
return ResponseEntity.ok(ApiResponse.ok(
String.format("%d건 중 %d건 성공, %d건 실패", result.totalCount(), result.successCount(), result.failCount()),
result
));
} catch (Exception e) {
return ResponseEntity.badRequest().body(ApiResponse.error("엑셀 파싱 실패: " + e.getMessage()));
}
}
@Operation(summary = "장비 일괄 등록 템플릿 다운로드")
@PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
@GetMapping("/batch/template")
public ResponseEntity<byte[]> downloadTemplate() throws Exception {
String[] headers = {"장비유형", "제조사", "모델명", "시리얼번호", "자산코드", "제원정보", "구매일"};
Map<Integer, String[]> dropdowns = Map.of(
0, new String[]{"LAPTOP", "MONITOR", "TEST_DEVICE", "ACCESSORY"}
);
byte[] data = ExcelParser.generateTemplate(headers, "장비 일괄등록", dropdowns);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=equipment_batch_template.xlsx")
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(data);
}
}
@@ -0,0 +1,28 @@
package com.srsystem.dto;
import java.util.ArrayList;
import java.util.List;
public record BatchResult<T>(
int totalCount,
int successCount,
int failCount,
List<Failure> failures,
List<T> data
) {
public static <T> BatchResult<T> ok(List<T> data) {
return new BatchResult<>(data.size(), data.size(), 0, List.of(), data);
}
public static <T> BatchResult<T> partial(List<T> successData, List<Failure> failures) {
return new BatchResult<>(
successData.size() + failures.size(),
successData.size(),
failures.size(),
failures,
successData
);
}
public record Failure(int row, String reason) {}
}
@@ -0,0 +1,147 @@
package com.srsystem.util;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.function.Function;
public class ExcelParser {
public static <T> ParseResult<T> parse(MultipartFile file, RowMapper<T> mapper, List<Validator<T>> validators) throws IOException {
List<T> data = new ArrayList<>();
List<ParseFailure> failures = new ArrayList<>();
try (InputStream is = file.getInputStream(); Workbook wb = new XSSFWorkbook(is)) {
Sheet sheet = wb.getSheetAt(0);
if (sheet.getPhysicalNumberOfRows() < 2) {
return new ParseResult<>(List.of(), List.of(new ParseFailure(0, "데이터 행이 없습니다")));
}
Row headerRow = sheet.getRow(0);
int colCount = headerRow.getPhysicalNumberOfCells();
String[] headers = new String[colCount];
for (int i = 0; i < colCount; i++) {
headers[i] = getCellValue(headerRow.getCell(i)).trim().toLowerCase();
}
for (int r = 1; r <= sheet.getLastRowNum(); r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
try {
Map<String, String> rowData = new HashMap<>();
for (int c = 0; c < colCount; c++) {
String val = getCellValue(row.getCell(c));
if (!val.isEmpty()) {
rowData.put(headers[c], val);
}
}
if (rowData.isEmpty()) continue;
T item = mapper.map(rowData);
for (Validator<T> v : validators) {
String err = v.validate(item, rowData);
if (err != null) {
throw new ValidationException(err);
}
}
data.add(item);
} catch (Exception e) {
failures.add(new ParseFailure(r + 1, e.getMessage()));
}
}
}
return new ParseResult<>(data, failures);
}
public static byte[] generateTemplate(String[] headers, String sheetName, Map<Integer, String[]> dropdowns) throws IOException {
try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
Sheet sheet = wb.createSheet(sheetName);
Row headerRow = sheet.createRow(0);
CellStyle headerStyle = wb.createCellStyle();
Font headerFont = wb.createFont();
headerFont.setBold(true);
headerFont.setColor(IndexedColors.WHITE.getIndex());
headerStyle.setFont(headerFont);
headerStyle.setFillForegroundColor(IndexedColors.INDIGO.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setBorderBottom(BorderStyle.THIN);
headerStyle.setBorderLeft(BorderStyle.THIN);
headerStyle.setBorderRight(BorderStyle.THIN);
headerStyle.setBorderTop(BorderStyle.THIN);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
sheet.autoSizeColumn(i);
}
if (dropdowns != null) {
for (Map.Entry<Integer, String[]> entry : dropdowns.entrySet()) {
int col = entry.getKey();
String[] values = entry.getValue();
DataValidationHelper helper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
CellRangeAddressList range = new CellRangeAddressList(1, 1000, col, col);
DataValidation validation = helper.createValidation(constraint, range);
validation.setShowErrorBox(true);
validation.createErrorBox("유효성 검사", "허용된 값만 입력 가능합니다.");
sheet.addValidationData(validation);
}
}
wb.write(bos);
return bos.toByteArray();
}
}
private static String getCellValue(Cell cell) {
if (cell == null) return "";
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue().trim();
case NUMERIC -> {
double val = cell.getNumericCellValue();
if (val == Math.floor(val) && !Double.isInfinite(val)) {
yield String.valueOf((long) val);
}
yield String.valueOf(val);
}
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> {
try { yield String.valueOf((int) cell.getNumericCellValue()); }
catch (Exception e) { yield cell.getStringCellValue(); }
}
default -> "";
};
}
public record ParseResult<T>(List<T> data, List<ParseFailure> failures) {
public boolean hasFailures() { return !failures.isEmpty(); }
}
public record ParseFailure(int row, String reason) {}
@FunctionalInterface
public interface RowMapper<T> {
T map(Map<String, String> rowData) throws Exception;
}
@FunctionalInterface
public interface Validator<T> {
String validate(T item, Map<String, String> rawData);
}
private static class ValidationException extends RuntimeException {
public ValidationException(String message) { super(message); }
}
}