feat: Excel batch registration (equipment + employees)
This commit is contained in:
@@ -34,6 +34,8 @@ dependencies {
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-batch'
|
||||
implementation 'org.postgresql:postgresql'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.2.5'
|
||||
implementation 'com.opencsv:opencsv:5.9'
|
||||
implementation 'org.projectlombok:lombok:1.18.38'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.38'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user