feat: 회원정보 xlsx로 담당자를 일괄 등록하는 업로드 API를 추가
POST /api/contacts/import로 회원정보 시트(Sheet1)를 업로드하면 구분(주관기관/ 변호사/신청기관/수행기관)에 따라 담당자를 등록·갱신하고, 신청기관 담당자는 기관명이 정확히 일치하는 기관에 자동 배정한다. 이미 등록된 사람은 findMatching으로 재사용하고 소속/부서/직급은 비어 있을 때만 채워 재업로드해도 안전하다. 확장자·빈 파일 검증은 SeedController와 동일한 방식을 따르고 SeedParseException을 재사용해 400으로 처리한다.
@a77ed96e8f773ced0fa04f5105c5ba7a7c34bff7
+++ src/main/java/kr/itn/itnhub/contact/MemberDirectoryParser.java
... | ... | @@ -0,0 +1,118 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.seed.SeedParseException; | |
| 4 | +import org.apache.poi.ss.usermodel.Cell; | |
| 5 | +import org.apache.poi.ss.usermodel.CellType; | |
| 6 | +import org.apache.poi.ss.usermodel.Row; | |
| 7 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 8 | +import org.apache.poi.ss.usermodel.Workbook; | |
| 9 | +import org.apache.poi.ss.usermodel.WorkbookFactory; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +import java.io.IOException; | |
| 13 | +import java.io.InputStream; | |
| 14 | +import java.util.ArrayList; | |
| 15 | +import java.util.List; | |
| 16 | +import java.util.Map; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * 회원정보 xlsx(Sheet1)를 읽는다. | |
| 20 | + * | |
| 21 | + * 시트 배치 (1행 헤더, 2행부터 데이터): | |
| 22 | + * A=순번 B=구분 C=기관명 D=부서명 E=담당자명 F=직급/직함 G=유선전화 H=이메일 | |
| 23 | + * | |
| 24 | + * 담당자명(E)이 빈 첫 행에서 데이터가 끝난 것으로 보고 읽기를 멈춘다. 구분(B)이 | |
| 25 | + * 알려진 값(주관기관/변호사/신청기관/수행기관)이 아니면 그 행은 건너뛰고 skipped로 | |
| 26 | + * 센다 - 시트 끝은 아니므로 이어서 다음 행을 계속 읽는다. | |
| 27 | + */ | |
| 28 | +@Component | |
| 29 | +public class MemberDirectoryParser { | |
| 30 | + | |
| 31 | + public static final String SHEET_NAME = "Sheet1"; | |
| 32 | + | |
| 33 | + private static final int DATA_START_ROW_INDEX = 1; // 2행 | |
| 34 | + private static final int COL_CATEGORY = 1; // B | |
| 35 | + private static final int COL_ORG_NAME = 2; // C | |
| 36 | + private static final int COL_DEPT = 3; // D | |
| 37 | + private static final int COL_NAME = 4; // E | |
| 38 | + private static final int COL_TITLE = 5; // F | |
| 39 | + private static final int COL_PHONE = 6; // G | |
| 40 | + private static final int COL_EMAIL = 7; // H | |
| 41 | + | |
| 42 | + private static final Map<String, String> CATEGORY_LABELS = Map.of( | |
| 43 | + "주관기관", "MJ", | |
| 44 | + "변호사", "LAWYER", | |
| 45 | + "신청기관", "APPLICANT", | |
| 46 | + "수행기관", "OPERATOR"); | |
| 47 | + | |
| 48 | + public MemberParseResult parse(InputStream xlsx) { | |
| 49 | + try (Workbook wb = WorkbookFactory.create(xlsx)) { | |
| 50 | + Sheet sheet = wb.getSheet(SHEET_NAME); | |
| 51 | + if (sheet == null) { | |
| 52 | + throw new SeedParseException( | |
| 53 | + "통합문서에 '" + SHEET_NAME + "' 시트가 없습니다."); | |
| 54 | + } | |
| 55 | + return readRows(sheet); | |
| 56 | + } catch (IOException e) { | |
| 57 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + private MemberParseResult readRows(Sheet sheet) { | |
| 62 | + List<MemberRow> rows = new ArrayList<>(); | |
| 63 | + int skipped = 0; | |
| 64 | + | |
| 65 | + for (int i = DATA_START_ROW_INDEX; i <= sheet.getLastRowNum(); i++) { | |
| 66 | + Row row = sheet.getRow(i); | |
| 67 | + String name = row == null ? "" : string(row, COL_NAME); | |
| 68 | + if (name.isBlank()) { | |
| 69 | + break; // 담당자명이 빈 첫 행에서 데이터 끝 | |
| 70 | + } | |
| 71 | + | |
| 72 | + String category = CATEGORY_LABELS.get(string(row, COL_CATEGORY)); | |
| 73 | + if (category == null) { | |
| 74 | + skipped++; | |
| 75 | + continue; | |
| 76 | + } | |
| 77 | + | |
| 78 | + rows.add(new MemberRow( | |
| 79 | + category, | |
| 80 | + nullIfBlank(string(row, COL_ORG_NAME)), | |
| 81 | + normalizeDept(string(row, COL_DEPT)), | |
| 82 | + name, | |
| 83 | + nullIfBlank(string(row, COL_TITLE)), | |
| 84 | + nullIfBlank(string(row, COL_PHONE)), | |
| 85 | + nullIfBlank(string(row, COL_EMAIL)))); | |
| 86 | + } | |
| 87 | + return new MemberParseResult(rows, skipped); | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** 부서명은 "-"와 공백 모두 값 없음으로 취급한다. */ | |
| 91 | + private String normalizeDept(String value) { | |
| 92 | + String v = nullIfBlank(value); | |
| 93 | + return "-".equals(v) ? null : v; | |
| 94 | + } | |
| 95 | + | |
| 96 | + private String string(Row row, int columnIndex) { | |
| 97 | + Cell cell = row.getCell(columnIndex); | |
| 98 | + if (cell == null) { | |
| 99 | + return ""; | |
| 100 | + } | |
| 101 | + return cellValue(cell, cell.getCellType()); | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** SeedParser와 동일한 셀 값 변환 규칙(FORMULA는 캐시된 결과 타입으로 위임). */ | |
| 105 | + private String cellValue(Cell cell, CellType cellType) { | |
| 106 | + return switch (cellType) { | |
| 107 | + case STRING -> cell.getStringCellValue().trim(); | |
| 108 | + case NUMERIC -> String.valueOf(Math.round(cell.getNumericCellValue())); | |
| 109 | + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); | |
| 110 | + case FORMULA -> cellValue(cell, cell.getCachedFormulaResultType()); | |
| 111 | + default -> ""; | |
| 112 | + }; | |
| 113 | + } | |
| 114 | + | |
| 115 | + private String nullIfBlank(String value) { | |
| 116 | + return (value == null || value.isBlank()) ? null : value; | |
| 117 | + } | |
| 118 | +} |
+++ src/main/java/kr/itn/itnhub/contact/MemberImportController.java
... | ... | @@ -0,0 +1,42 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.seed.SeedParseException; | |
| 4 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 5 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 6 | +import org.springframework.web.bind.annotation.RestController; | |
| 7 | +import org.springframework.web.multipart.MultipartFile; | |
| 8 | + | |
| 9 | +import java.io.IOException; | |
| 10 | +import java.io.InputStream; | |
| 11 | + | |
| 12 | +/** 회원정보 xlsx 업로드. SeedController와 동일한 확장자/빈 파일 검증을 쓴다. */ | |
| 13 | +@RestController | |
| 14 | +public class MemberImportController { | |
| 15 | + | |
| 16 | + private final MemberImportService importService; | |
| 17 | + | |
| 18 | + public MemberImportController(MemberImportService importService) { | |
| 19 | + this.importService = importService; | |
| 20 | + } | |
| 21 | + | |
| 22 | + @PostMapping("/api/contacts/import") | |
| 23 | + public MemberImportReport upload(@RequestParam("file") MultipartFile file) { | |
| 24 | + if (file.isEmpty()) { | |
| 25 | + throw new SeedParseException("업로드된 파일이 비어 있습니다."); | |
| 26 | + } | |
| 27 | + String filename = file.getOriginalFilename(); | |
| 28 | + if (filename == null || !hasAllowedExtension(filename)) { | |
| 29 | + throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다."); | |
| 30 | + } | |
| 31 | + try (InputStream in = file.getInputStream()) { | |
| 32 | + return importService.importFile(in); | |
| 33 | + } catch (IOException e) { | |
| 34 | + throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e); | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + private static boolean hasAllowedExtension(String filename) { | |
| 39 | + String lower = filename.toLowerCase(); | |
| 40 | + return lower.endsWith(".xlsx") || lower.endsWith(".xlsm"); | |
| 41 | + } | |
| 42 | +} |
+++ src/main/java/kr/itn/itnhub/contact/MemberImportReport.java
... | ... | @@ -0,0 +1,5 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +/** 회원명단 업로드 결과. assigned는 신청기관 담당자로 자동 배정된 기관 수. */ | |
| 4 | +public record MemberImportReport(int created, int updated, int skipped, int assigned) { | |
| 5 | +} |
+++ src/main/java/kr/itn/itnhub/contact/MemberImportService.java
... | ... | @@ -0,0 +1,103 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.org.Organization; | |
| 4 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 5 | +import org.springframework.stereotype.Service; | |
| 6 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 7 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 8 | + | |
| 9 | +import java.io.InputStream; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * 회원정보 xlsx를 읽어 담당자를 등록/갱신하고, 신청기관 담당자는 같은 이름의 기관에 | |
| 13 | + * 자동 배정한다. 재업로드해도 안전해야 한다(SeedService와 같은 원칙) - 같은 사람이 | |
| 14 | + * 다시 올라오면 새로 만들지 않고 기존 담당자를 찾아 재사용한다. | |
| 15 | + * | |
| 16 | + * <p>엑셀 파싱은 DB와 무관하므로 트랜잭션 밖에서 수행한다(kr.itn.itnhub.seed.SeedService와 | |
| 17 | + * 동일한 이유 - 같은 빈의 @Transactional 메서드를 self-invocation으로 호출하면 프록시를 | |
| 18 | + * 우회해 트랜잭션이 적용되지 않으므로 TransactionTemplate으로 감싼다).</p> | |
| 19 | + */ | |
| 20 | +@Service | |
| 21 | +public class MemberImportService { | |
| 22 | + | |
| 23 | + private final MemberDirectoryParser parser; | |
| 24 | + private final ContactMapper contactMapper; | |
| 25 | + private final OrganizationMapper orgMapper; | |
| 26 | + private final TransactionTemplate transactionTemplate; | |
| 27 | + | |
| 28 | + public MemberImportService(MemberDirectoryParser parser, ContactMapper contactMapper, | |
| 29 | + OrganizationMapper orgMapper, PlatformTransactionManager transactionManager) { | |
| 30 | + this.parser = parser; | |
| 31 | + this.contactMapper = contactMapper; | |
| 32 | + this.orgMapper = orgMapper; | |
| 33 | + this.transactionTemplate = new TransactionTemplate(transactionManager); | |
| 34 | + } | |
| 35 | + | |
| 36 | + public MemberImportReport importFile(InputStream xlsx) { | |
| 37 | + MemberParseResult parsed = parser.parse(xlsx); | |
| 38 | + return transactionTemplate.execute(status -> importRows(parsed)); | |
| 39 | + } | |
| 40 | + | |
| 41 | + private MemberImportReport importRows(MemberParseResult parsed) { | |
| 42 | + int created = 0; | |
| 43 | + int updated = 0; | |
| 44 | + int assigned = 0; | |
| 45 | + | |
| 46 | + for (MemberRow row : parsed.rows()) { | |
| 47 | + Contact existing = contactMapper.findMatching( | |
| 48 | + row.category(), row.name(), row.phone(), row.email()); | |
| 49 | + | |
| 50 | + Contact contact; | |
| 51 | + if (existing == null) { | |
| 52 | + contact = new Contact(); | |
| 53 | + contact.setCategory(row.category()); | |
| 54 | + contact.setName(row.name()); | |
| 55 | + contact.setAffiliation(row.affiliation()); | |
| 56 | + contact.setDeptName(row.deptName()); | |
| 57 | + contact.setTitle(row.title()); | |
| 58 | + contact.setPhone(row.phone()); | |
| 59 | + contact.setEmail(row.email()); | |
| 60 | + contactMapper.insert(contact); | |
| 61 | + created++; | |
| 62 | + } else { | |
| 63 | + fillMissingFields(existing, row); | |
| 64 | + contactMapper.update(existing); | |
| 65 | + updated++; | |
| 66 | + contact = existing; | |
| 67 | + } | |
| 68 | + | |
| 69 | + if ("APPLICANT".equals(row.category()) && tryAssignApplicant(row, contact)) { | |
| 70 | + assigned++; | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + return new MemberImportReport(created, updated, parsed.skipped(), assigned); | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** 기존 담당자의 소속/부서/직급은 이미 값이 있으면 건드리지 않고, 비어 있을 때만 채운다. */ | |
| 78 | + private void fillMissingFields(Contact existing, MemberRow row) { | |
| 79 | + if (existing.getAffiliation() == null && row.affiliation() != null) { | |
| 80 | + existing.setAffiliation(row.affiliation()); | |
| 81 | + } | |
| 82 | + if (existing.getDeptName() == null && row.deptName() != null) { | |
| 83 | + existing.setDeptName(row.deptName()); | |
| 84 | + } | |
| 85 | + if (existing.getTitle() == null && row.title() != null) { | |
| 86 | + existing.setTitle(row.title()); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** 기관명이 정확히 일치하고 아직 신청기관 담당자가 배정되지 않은 기관에만 배정한다. */ | |
| 91 | + private boolean tryAssignApplicant(MemberRow row, Contact contact) { | |
| 92 | + if (row.affiliation() == null) { | |
| 93 | + return false; | |
| 94 | + } | |
| 95 | + Organization org = orgMapper.findByOrgName(row.affiliation()); | |
| 96 | + if (org == null || org.getApplicantContactId() != null) { | |
| 97 | + return false; | |
| 98 | + } | |
| 99 | + orgMapper.updateAssignments(org.getId(), contact.getId(), | |
| 100 | + org.getMjContactId(), org.getLawyerContactId(), org.getLawyerAssignedDate()); | |
| 101 | + return true; | |
| 102 | + } | |
| 103 | +} |
+++ src/main/java/kr/itn/itnhub/contact/MemberParseResult.java
... | ... | @@ -0,0 +1,7 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +/** 회원명단 파싱 결과. skipped는 구분(카테고리)을 알아볼 수 없어 건너뛴 행 수다. */ | |
| 6 | +public record MemberParseResult(List<MemberRow> rows, int skipped) { | |
| 7 | +} |
+++ src/main/java/kr/itn/itnhub/contact/MemberRow.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +/** 회원명단 시트 한 행. category는 이미 내부 코드(APPLICANT/MJ/LAWYER/OPERATOR)로 매핑된 값이다. */ | |
| 4 | +public record MemberRow( | |
| 5 | + String category, | |
| 6 | + String affiliation, | |
| 7 | + String deptName, | |
| 8 | + String name, | |
| 9 | + String title, | |
| 10 | + String phone, | |
| 11 | + String email) { | |
| 12 | +} |
+++ src/test/java/kr/itn/itnhub/contact/MemberDirectoryParserTest.java
... | ... | @@ -0,0 +1,75 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +import org.apache.poi.ss.usermodel.Row; | |
| 4 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 5 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | + | |
| 8 | +import java.io.ByteArrayInputStream; | |
| 9 | +import java.io.ByteArrayOutputStream; | |
| 10 | +import java.util.List; | |
| 11 | + | |
| 12 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 13 | + | |
| 14 | +class MemberDirectoryParserTest { | |
| 15 | + | |
| 16 | + private final MemberDirectoryParser parser = new MemberDirectoryParser(); | |
| 17 | + | |
| 18 | + private byte[] workbook(String[]... dataRows) throws Exception { | |
| 19 | + try (XSSFWorkbook wb = new XSSFWorkbook(); | |
| 20 | + ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 21 | + | |
| 22 | + Sheet sheet = wb.createSheet(MemberDirectoryParser.SHEET_NAME); | |
| 23 | + sheet.createRow(0); // 헤더 | |
| 24 | + | |
| 25 | + int rowNum = 1; | |
| 26 | + for (String[] data : dataRows) { | |
| 27 | + Row row = sheet.createRow(rowNum++); | |
| 28 | + for (int i = 0; i < data.length; i++) { | |
| 29 | + if (data[i] == null) { | |
| 30 | + continue; | |
| 31 | + } | |
| 32 | + row.createCell(i).setCellValue(data[i]); | |
| 33 | + } | |
| 34 | + } | |
| 35 | + wb.write(out); | |
| 36 | + return out.toByteArray(); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Test | |
| 41 | + void 담당자명이_빈_첫_행에서_읽기를_멈춘다() throws Exception { | |
| 42 | + byte[] xlsx = workbook( | |
| 43 | + new String[]{"1", "신청기관", "기관A", "팀", "김담당", "과장", "02-1", "a@b.kr"}, | |
| 44 | + new String[]{"2", "신청기관", "기관B", null, null, null, null, null}, | |
| 45 | + new String[]{"3", "신청기관", "기관C", "팀", "이담당", "대리", "02-3", "c@d.kr"}); | |
| 46 | + | |
| 47 | + MemberParseResult result = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 48 | + | |
| 49 | + assertThat(result.rows()).hasSize(1); | |
| 50 | + assertThat(result.rows().get(0).name()).isEqualTo("김담당"); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @Test | |
| 54 | + void 부서명이_대시나_공백이면_null로_취급한다() throws Exception { | |
| 55 | + byte[] xlsx = workbook( | |
| 56 | + new String[]{"1", "변호사", null, "-", "이변호", null, "02-1", "a@b.kr"}); | |
| 57 | + | |
| 58 | + List<MemberRow> rows = parser.parse(new ByteArrayInputStream(xlsx)).rows(); | |
| 59 | + | |
| 60 | + assertThat(rows.get(0).deptName()).isNull(); | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + void 알수없는_구분은_건너뛰고_계속_읽는다() throws Exception { | |
| 65 | + byte[] xlsx = workbook( | |
| 66 | + new String[]{"1", "이상한구분", "기관A", "팀", "김담당", "과장", "02-1", "a@b.kr"}, | |
| 67 | + new String[]{"2", "변호사", null, null, "이변호", null, "02-2", "b@c.kr"}); | |
| 68 | + | |
| 69 | + MemberParseResult result = parser.parse(new ByteArrayInputStream(xlsx)); | |
| 70 | + | |
| 71 | + assertThat(result.skipped()).isEqualTo(1); | |
| 72 | + assertThat(result.rows()).hasSize(1); | |
| 73 | + assertThat(result.rows().get(0).name()).isEqualTo("이변호"); | |
| 74 | + } | |
| 75 | +} |
+++ src/test/java/kr/itn/itnhub/contact/MemberImportControllerTest.java
... | ... | @@ -0,0 +1,186 @@ |
| 1 | +package kr.itn.itnhub.contact; | |
| 2 | + | |
| 3 | +import kr.itn.itnhub.AbstractDbTest; | |
| 4 | +import kr.itn.itnhub.org.Organization; | |
| 5 | +import kr.itn.itnhub.org.OrganizationMapper; | |
| 6 | +import org.apache.poi.ss.usermodel.Row; | |
| 7 | +import org.apache.poi.ss.usermodel.Sheet; | |
| 8 | +import org.apache.poi.xssf.usermodel.XSSFWorkbook; | |
| 9 | +import org.junit.jupiter.api.BeforeEach; | |
| 10 | +import org.junit.jupiter.api.Test; | |
| 11 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 12 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 13 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 14 | +import org.springframework.mock.web.MockMultipartFile; | |
| 15 | +import org.springframework.security.test.context.support.WithMockUser; | |
| 16 | +import org.springframework.test.web.servlet.MockMvc; | |
| 17 | + | |
| 18 | +import java.io.ByteArrayOutputStream; | |
| 19 | +import java.util.List; | |
| 20 | + | |
| 21 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 22 | +import static org.hamcrest.Matchers.containsString; | |
| 23 | +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; | |
| 24 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; | |
| 25 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 26 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 27 | + | |
| 28 | +@AutoConfigureMockMvc | |
| 29 | +@WithMockUser(roles = "ADMIN") | |
| 30 | +class MemberImportControllerTest extends AbstractDbTest { | |
| 31 | + | |
| 32 | + @Autowired | |
| 33 | + MockMvc mvc; | |
| 34 | + | |
| 35 | + @Autowired | |
| 36 | + ContactMapper contactMapper; | |
| 37 | + | |
| 38 | + @Autowired | |
| 39 | + OrganizationMapper orgMapper; | |
| 40 | + | |
| 41 | + @Autowired | |
| 42 | + JdbcTemplate jdbc; | |
| 43 | + | |
| 44 | + @BeforeEach | |
| 45 | + void clean() { | |
| 46 | + jdbc.update("delete from organization"); | |
| 47 | + jdbc.update("delete from contact"); | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** 회원정보 시트: 1행 헤더, 2행부터 데이터. A=순번 B=구분 C=기관명 D=부서명 E=담당자명 F=직급 G=전화 H=이메일 */ | |
| 51 | + private MockMultipartFile workbook(String[]... dataRows) throws Exception { | |
| 52 | + try (XSSFWorkbook wb = new XSSFWorkbook(); | |
| 53 | + ByteArrayOutputStream out = new ByteArrayOutputStream()) { | |
| 54 | + | |
| 55 | + Sheet sheet = wb.createSheet(MemberDirectoryParser.SHEET_NAME); | |
| 56 | + Row header = sheet.createRow(0); | |
| 57 | + String[] headers = {"순번", "구분", "기관명", "부서명", "담당자명", "직급/직함", "유선전화", "이메일"}; | |
| 58 | + for (int i = 0; i < headers.length; i++) { | |
| 59 | + header.createCell(i).setCellValue(headers[i]); | |
| 60 | + } | |
| 61 | + | |
| 62 | + int rowNum = 1; | |
| 63 | + for (String[] data : dataRows) { | |
| 64 | + Row row = sheet.createRow(rowNum++); | |
| 65 | + for (int i = 0; i < data.length; i++) { | |
| 66 | + if (data[i] == null) { | |
| 67 | + continue; | |
| 68 | + } | |
| 69 | + row.createCell(i).setCellValue(data[i]); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + wb.write(out); | |
| 73 | + return new MockMultipartFile("file", "members.xlsx", | |
| 74 | + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| 75 | + out.toByteArray()); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test | |
| 80 | + void 구분을_주관기관_변호사_신청기관_수행기관으로_각각_매핑한다() throws Exception { | |
| 81 | + MockMultipartFile file = workbook( | |
| 82 | + new String[]{"1", "주관기관", null, "저작권정책과", "김문정", "사무관", "02-1", "mj@mcst.go.kr"}, | |
| 83 | + new String[]{"2", "변호사", null, null, "이변호", null, "02-2", "law@firm.kr"}, | |
| 84 | + new String[]{"3", "신청기관", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-3", "ming@arirang.com"}, | |
| 85 | + new String[]{"4", "수행기관", "수행사", "운영팀", "홍수행", "대리", "02-4", "op@vendor.kr"}); | |
| 86 | + | |
| 87 | + mvc.perform(multipart("/api/contacts/import").file(file).with(csrf())) | |
| 88 | + .andExpect(status().isOk()) | |
| 89 | + .andExpect(jsonPath("$.created").value(4)) | |
| 90 | + .andExpect(jsonPath("$.skipped").value(0)); | |
| 91 | + | |
| 92 | + List<Contact> all = contactMapper.findAll(null); | |
| 93 | + assertThat(all).hasSize(4); | |
| 94 | + assertThat(all).extracting(Contact::getCategory) | |
| 95 | + .containsExactlyInAnyOrder("MJ", "LAWYER", "APPLICANT", "OPERATOR"); | |
| 96 | + | |
| 97 | + Contact operator = all.stream().filter(c -> "OPERATOR".equals(c.getCategory())).findFirst().orElseThrow(); | |
| 98 | + assertThat(operator.getName()).isEqualTo("홍수행"); | |
| 99 | + assertThat(operator.getAffiliation()).isEqualTo("수행사"); | |
| 100 | + assertThat(operator.getDeptName()).isEqualTo("운영팀"); | |
| 101 | + } | |
| 102 | + | |
| 103 | + @Test | |
| 104 | + void 같은_사람이_여러_행에_있으면_한_명만_생성되고_두번째부터는_갱신으로_집계된다() throws Exception { | |
| 105 | + MockMultipartFile file = workbook( | |
| 106 | + new String[]{"1", "신청기관", "경찰청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"}, | |
| 107 | + new String[]{"2", "신청기관", "서울청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"}, | |
| 108 | + new String[]{"3", "신청기관", "부산청", "데이터정책계", "박진우", "경위", "02-3150-3205", "hi@police.go.kr"}); | |
| 109 | + | |
| 110 | + mvc.perform(multipart("/api/contacts/import").file(file).with(csrf())) | |
| 111 | + .andExpect(status().isOk()) | |
| 112 | + .andExpect(jsonPath("$.created").value(1)) | |
| 113 | + .andExpect(jsonPath("$.updated").value(2)); | |
| 114 | + | |
| 115 | + assertThat(jdbc.queryForObject("select count(*) from contact", Integer.class)).isEqualTo(1); | |
| 116 | + } | |
| 117 | + | |
| 118 | + @Test | |
| 119 | + void 신청기관_담당자는_기관명이_같은_기관에_자동_배정된다() throws Exception { | |
| 120 | + Organization org = new Organization(); | |
| 121 | + org.setOrgNo("001_00"); | |
| 122 | + org.setOrgName("국제방송교류재단"); | |
| 123 | + org.setChannelSlug("001_00"); | |
| 124 | + orgMapper.upsertBySeed(org); | |
| 125 | + Long orgId = orgMapper.findAll().get(0).getId(); | |
| 126 | + assertThat(orgMapper.findById(orgId).getApplicantContactId()).isNull(); | |
| 127 | + | |
| 128 | + MockMultipartFile file = workbook(new String[]{ | |
| 129 | + "1", "신청기관", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-3475-5434", "ming@arirang.com"}); | |
| 130 | + | |
| 131 | + mvc.perform(multipart("/api/contacts/import").file(file).with(csrf())) | |
| 132 | + .andExpect(status().isOk()) | |
| 133 | + .andExpect(jsonPath("$.assigned").value(1)); | |
| 134 | + | |
| 135 | + Organization after = orgMapper.findById(orgId); | |
| 136 | + assertThat(after.getApplicantContactId()).isNotNull(); | |
| 137 | + assertThat(after.getApplicant().getName()).isEqualTo("송민지"); | |
| 138 | + } | |
| 139 | + | |
| 140 | + @Test | |
| 141 | + void 이미_배정된_기관은_재업로드해도_배정이_바뀌지_않는다() throws Exception { | |
| 142 | + Organization org = new Organization(); | |
| 143 | + org.setOrgNo("001_00"); | |
| 144 | + org.setOrgName("국제방송교류재단"); | |
| 145 | + org.setChannelSlug("001_00"); | |
| 146 | + orgMapper.upsertBySeed(org); | |
| 147 | + Long orgId = orgMapper.findAll().get(0).getId(); | |
| 148 | + | |
| 149 | + MockMultipartFile file = workbook(new String[]{ | |
| 150 | + "1", "신청기관", "국제방송교류재단", "데이터정보화팀", "송민지", "과장", "02-3475-5434", "ming@arirang.com"}); | |
| 151 | + | |
| 152 | + mvc.perform(multipart("/api/contacts/import").file(file).with(csrf())) | |
| 153 | + .andExpect(status().isOk()); | |
| 154 | + Long firstContactId = orgMapper.findById(orgId).getApplicantContactId(); | |
| 155 | + | |
| 156 | + mvc.perform(multipart("/api/contacts/import").file(file).with(csrf())) | |
| 157 | + .andExpect(status().isOk()) | |
| 158 | + .andExpect(jsonPath("$.assigned").value(0)); | |
| 159 | + | |
| 160 | + assertThat(orgMapper.findById(orgId).getApplicantContactId()).isEqualTo(firstContactId); | |
| 161 | + } | |
| 162 | + | |
| 163 | + @Test | |
| 164 | + void 알수없는_구분은_건너뛰고_skipped로_집계된다() throws Exception { | |
| 165 | + MockMultipartFile file = workbook( | |
| 166 | + new String[]{"1", "신청기관", "국제방송교류재단", "팀", "송민지", "과장", "02-1", "a@b.kr"}, | |
| 167 | + new String[]{"2", "알수없음", "어떤기관", "팀", "홍길동", "대리", "02-2", "b@c.kr"}); | |
| 168 | + | |
| 169 | + mvc.perform(multipart("/api/contacts/import").file(file).with(csrf())) | |
| 170 | + .andExpect(status().isOk()) | |
| 171 | + .andExpect(jsonPath("$.created").value(1)) | |
| 172 | + .andExpect(jsonPath("$.skipped").value(1)); | |
| 173 | + | |
| 174 | + assertThat(jdbc.queryForObject("select count(*) from contact", Integer.class)).isEqualTo(1); | |
| 175 | + } | |
| 176 | + | |
| 177 | + @Test | |
| 178 | + void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception { | |
| 179 | + MockMultipartFile txt = new MockMultipartFile("file", "members.txt", | |
| 180 | + "text/plain", "아무 내용".getBytes()); | |
| 181 | + | |
| 182 | + mvc.perform(multipart("/api/contacts/import").file(txt).with(csrf())) | |
| 183 | + .andExpect(status().isBadRequest()) | |
| 184 | + .andExpect(jsonPath("$.message").value(containsString("xlsx"))); | |
| 185 | + } | |
| 186 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?