From 16cb060565277709247e1537ddbbb5cea0eef2bd Mon Sep 17 00:00:00 2001 From: treegus Date: Tue, 6 Oct 2026 14:24:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=91=EC=85=80=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=EC=9D=B4=20=EC=9D=B8=EC=A0=81=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=97=86=EB=8A=94=20=EB=8C=80=EC=83=81=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=8C=EB=93=A4=EC=96=B4=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/certificate-domain.md | 10 +- .../dto/MappingConfirmResponse.java | 9 +- .../file/excel/ColumnMappingTable.java | 7 +- .../file/service/MappingConfirmService.java | 64 ++++++++-- .../service/MappingConfirmServiceTest.java | 113 +++++++++++++++++- 5 files changed, 182 insertions(+), 21 deletions(-) diff --git a/docs/certificate-domain.md b/docs/certificate-domain.md index 4c9b12b..313aabd 100644 --- a/docs/certificate-domain.md +++ b/docs/certificate-domain.md @@ -106,7 +106,15 @@ SELECT certificate_id, hire_date, expiration_date, retirement_date FROM certific `human_id`는 **nullable로 둔다.** (2)의 백필로 매칭 안 되는 행 = 인적사항이 아직 등록 안 된 사람이고, 그 행은 발급 대상에서 빠진다. -엑셀 일괄 등록(`MappingConfirmService`)은 #35부터 행마다 (성명, 생년월일)로 `humans`를 찾아 `human_id`를 채운다. 인적사항이 없는 행은 저장하지 않고 `failedRows`로 돌려준다. 그 전에 업로드돼 `human_id`가 NULL로 남은 행은 (2)의 백필을 한 번 더 돌려 보정한다. +엑셀 일괄 등록(`MappingConfirmService`)은 #35부터 행마다 (성명, 생년월일)로 `humans`를 찾아 `human_id`를 채운다. 그 전에 업로드돼 `human_id`가 NULL로 남은 행은 (2)의 백필을 한 번 더 돌려 보정한다. + +#81부터 **인적사항이 없는 행은 실패시키지 않고 `humans`를 만들어 연결한다.** 그 전에는 개별 등록으로 사람을 미리 만들어 두지 않은 엑셀이 통째로 올라가지 않아서, 엑셀만 가진 담당자가 쓸 수 없었다. + +엑셀 필수 매핑(`name`/`birthDate`/`gender`)이 `humans`의 NOT NULL 컬럼과 정확히 일치하므로 추가 입력 없이 만들 수 있다. 주소는 선택 매핑(`address`)이고, 매핑하지 않으면 `humans.address`가 NULL로 남아 서식의 주소 칸이 공란으로 찍힌다 — 틀린 주소를 인쇄하는 것보다 낫다. `humans.department`는 채우지 않는다(§1-2). + +**이미 있는 인적사항은 엑셀 값으로 덮어쓰지 않는다.** 성별이 달라도 `humans`를 따른다 — 검수된 데이터가 우선이다. 개별 등록(`CertificateService.create`)과 같은 기준이다. + +> ⚠️ 대상자 식별 키가 (성명, 생년월일)뿐이다. **성명 오타 한 글자가 별개 인물을 만든다.** 응답의 `createdHumanCount`로 담당자가 확인할 수 있게 해 뒀지만, 동명이인이 같은 생년월일을 가지면 한 사람으로 합쳐지는 한계는 그대로다 (§7-1). ```sql -- 백필 후 매칭 실패 행 확인. 0이 아니면 human 등록부터. diff --git a/src/main/java/commonly/commonlybe/file/controller/dto/MappingConfirmResponse.java b/src/main/java/commonly/commonlybe/file/controller/dto/MappingConfirmResponse.java index 327cae6..fc02878 100644 --- a/src/main/java/commonly/commonlybe/file/controller/dto/MappingConfirmResponse.java +++ b/src/main/java/commonly/commonlybe/file/controller/dto/MappingConfirmResponse.java @@ -2,5 +2,12 @@ import java.util.List; -public record MappingConfirmResponse(boolean saved, int insertedCount, List failedRows) { +/** + * @param createdHumanCount 엑셀에만 있던 대상자를 새로 만든 수. + * 성명 오타 한 글자가 별개 인물을 만들 수 있어 담당자가 확인할 수 있게 내려준다. + */ +public record MappingConfirmResponse(boolean saved, + int insertedCount, + int createdHumanCount, + List failedRows) { } diff --git a/src/main/java/commonly/commonlybe/file/excel/ColumnMappingTable.java b/src/main/java/commonly/commonlybe/file/excel/ColumnMappingTable.java index dfd28b6..83ca0e8 100644 --- a/src/main/java/commonly/commonlybe/file/excel/ColumnMappingTable.java +++ b/src/main/java/commonly/commonlybe/file/excel/ColumnMappingTable.java @@ -5,11 +5,14 @@ public final class ColumnMappingTable { /** - * department는 선택이다. 부서 열이 없는 기존 운영 엑셀도 그대로 올라가야 하므로 + * department와 address는 선택이다. 해당 열이 없는 기존 운영 엑셀도 그대로 올라가야 하므로 * 필수로 올리면 안 된다 (certificate-domain.md §1-2). + * + * address는 certificate가 아니라 humans로 들어간다 — 엑셀로 처음 등장한 대상자의 + * 인적사항을 만들 때 쓴다. 매핑하지 않으면 증명서 서식의 주소 칸이 공란으로 찍힌다. */ private static final Set VALID_TARGET_FIELDS = Set.of( - "name", "birthDate", "gender", "jobTitle", "keyResponsibilities", + "name", "birthDate", "gender", "address", "jobTitle", "keyResponsibilities", "hireDate", "expirationDate", "retirementDate", "division", "department", "reason", "employmentType", "note" ); diff --git a/src/main/java/commonly/commonlybe/file/service/MappingConfirmService.java b/src/main/java/commonly/commonlybe/file/service/MappingConfirmService.java index 1a69338..5a96758 100644 --- a/src/main/java/commonly/commonlybe/file/service/MappingConfirmService.java +++ b/src/main/java/commonly/commonlybe/file/service/MappingConfirmService.java @@ -58,7 +58,7 @@ public MappingConfirmResponse confirm(Long fileId, MappingConfirmRequest request validateMappings(request.mappings(), columnIndex.keySet()); if (!request.confirmed()) { - return new MappingConfirmResponse(false, 0, List.of()); + return new MappingConfirmResponse(false, 0, 0, List.of()); } // 확정은 파일당 한 번만 허용한다. 조건부 UPDATE라 동시 요청 중 하나만 통과한다. @@ -68,24 +68,30 @@ public MappingConfirmResponse confirm(Long fileId, MappingConfirmRequest request List toInsert = new ArrayList<>(); List failedRows = new ArrayList<>(); - // 한 사람의 이력이 여러 행에 걸치므로 (성명, 생년월일)당 한 번만 조회한다. - Map> humans = new HashMap<>(); + // 한 사람의 이력이 여러 행에 걸치므로 (성명, 생년월일)당 한 번만 조회·생성한다. + Map humans = new HashMap<>(); + int createdHumanCount = 0; for (ParsedRow row : parsedExcel.rows()) { Map fieldValues = extractFieldValues(row, request.mappings(), columnIndex); switch (RowValidator.validate(fieldValues)) { case RowResult.Success success -> { CertificateEntity certificate = success.certificate(); - Optional human = humans.computeIfAbsent( - new HumanKey(certificate.getName(), certificate.getBirthDate()), - key -> humanRepository.findByNameAndBirthDate(key.name(), key.birthDate())); - if (human.isPresent()) { - linkHuman(certificate, human.get()); - toInsert.add(certificate); - } else { - failedRows.add(new FailedRowDto(row.rowIndex(), - "인적사항이 등록되지 않은 대상자입니다 (성명/생년월일)")); + HumanKey key = new HumanKey(certificate.getName(), certificate.getBirthDate()); + + HumanEntity human = humans.get(key); + if (human == null) { + human = humanRepository.findByNameAndBirthDate(key.name(), key.birthDate()) + .orElse(null); + if (human == null) { + human = createHuman(certificate, trimToNull(fieldValues.get("address"))); + createdHumanCount++; + } + humans.put(key, human); } + + linkHuman(certificate, human); + toInsert.add(certificate); } case RowResult.Failure failure -> failedRows.add(new FailedRowDto(row.rowIndex(), failure.reason())); case RowResult.Skip skip -> { } @@ -94,7 +100,39 @@ public MappingConfirmResponse confirm(Long fileId, MappingConfirmRequest request certificateRepository.saveAll(toInsert); - return new MappingConfirmResponse(true, toInsert.size(), failedRows); + return new MappingConfirmResponse(true, toInsert.size(), createdHumanCount, failedRows); + } + + /** + * 엑셀에만 있는 대상자는 인적사항을 만들어 준다. 전에는 행을 실패시켰는데, + * 그러면 개별 등록으로 사람을 먼저 만들어 두지 않은 엑셀은 통째로 올라가지 않았다 (#81). + * + * 엑셀 필수 매핑(성명/생년월일/성별)이 humans의 NOT NULL 컬럼과 정확히 일치하므로 + * 추가 입력 없이 만들 수 있다. 주소는 선택 매핑이고, 없으면 null로 두고 + * 증명서 서식의 주소 칸은 공란으로 찍힌다 — 틀린 주소를 인쇄하는 것보다 낫다. + * + * department는 채우지 않는다. humans.department는 사람당 한 개라 + * 기간별 부서를 표현할 수 없고, 재직 이력의 부서는 certificate.department가 따로 갖는다. + * + * ponytail: 같은 사람이 든 엑셀을 두 담당자가 동시에 확정하면 uk_humans_name_birth_date + * 위반으로 트랜잭션이 깨진다. 창구 운영에서는 발생하지 않는다고 보고 재시도를 넣지 않았다. + */ + private HumanEntity createHuman(CertificateEntity certificate, String address) { + return humanRepository.save(HumanEntity.builder() + .name(certificate.getName()) + .birthDate(certificate.getBirthDate()) + // 이름만 같은 별개 enum이다. 상수 이름(MALE/FEMALE)은 양쪽이 같다. + .gender(commonly.commonlybe.human.entity.Gender.valueOf(certificate.getGender().name())) + .address(address) + .build()); + } + + private static String trimToNull(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + return trimmed.isEmpty() ? null : trimmed; } /** diff --git a/src/test/java/commonly/commonlybe/file/service/MappingConfirmServiceTest.java b/src/test/java/commonly/commonlybe/file/service/MappingConfirmServiceTest.java index 5eabdae..506309a 100644 --- a/src/test/java/commonly/commonlybe/file/service/MappingConfirmServiceTest.java +++ b/src/test/java/commonly/commonlybe/file/service/MappingConfirmServiceTest.java @@ -46,12 +46,16 @@ class MappingConfirmServiceTest { private static final Long FILE_ID = 1L; private static final Long HUMAN_ID = 10L; + private static final Long NEW_HUMAN_ID = 11L; private static final String OBJECT_KEY = "excel/upload.xlsx"; private static final LocalDate BIRTH_DATE = LocalDate.of(1990, 1, 1); private static final List HEADERS = List.of("성명", "생년월일", "성별", "채용일"); /** 부서 열은 FE가 나중에 추가한 열이다. 기존 서식에는 없어서 별도 헤더로 둔다 (#63). */ private static final List HEADERS_WITH_DEPARTMENT = List.of("성명", "생년월일", "성별", "채용일", "근무부서"); + /** 주소는 certificate가 아니라 humans로 들어간다 (#81). */ + private static final List HEADERS_WITH_ADDRESS = + List.of("성명", "생년월일", "성별", "채용일", "주소"); @Mock private FileRepository fileRepository; @@ -97,20 +101,115 @@ void setUp() { verify(humanRepository, times(1)).findByNameAndBirthDate(any(), any()); } + /** + * 전에는 이 행을 실패시켰다. 그러면 개별 등록으로 사람을 먼저 만들어 두지 않은 엑셀이 + * 통째로 올라가지 않아서, 엑셀만 가진 담당자가 쓸 수 없었다 (#81). + */ @Test - void 인적사항이_없는_대상자는_행_오류로_돌려준다() throws IOException { + void 인적사항이_없는_대상자는_인적사항을_만들어_연결한다() throws IOException { givenUploadedExcel(List.of( List.of("홍길동", "1990-01-01", "남", "2020-01-01"), List.of("김철수", "1985-05-05", "남", "2021-01-01"))); given(humanRepository.findByNameAndBirthDate("홍길동", BIRTH_DATE)).willReturn(Optional.of(human())); given(humanRepository.findByNameAndBirthDate("김철수", LocalDate.of(1985, 5, 5))) .willReturn(Optional.empty()); + given(humanRepository.save(any())).willAnswer(invocation -> { + HumanEntity created = invocation.getArgument(0); + ReflectionTestUtils.setField(created, "humanId", NEW_HUMAN_ID); + return created; + }); MappingConfirmResponse response = mappingConfirmService.confirm(FILE_ID, confirmRequest()); - assertThat(response.insertedCount()).isEqualTo(1); - assertThat(response.failedRows()).extracting(FailedRowDto::rowIndex).containsExactly(3); - assertThat(captureSaved()).extracting(CertificateEntity::getHumanId).containsExactly(HUMAN_ID); + assertThat(response.insertedCount()).isEqualTo(2); + assertThat(response.failedRows()).isEmpty(); + assertThat(response.createdHumanCount()).isEqualTo(1); + assertThat(captureSaved()).extracting(CertificateEntity::getHumanId) + .containsExactly(HUMAN_ID, NEW_HUMAN_ID); + + HumanEntity created = captureSavedHuman(); + assertThat(created.getName()).isEqualTo("김철수"); + assertThat(created.getBirthDate()).isEqualTo(LocalDate.of(1985, 5, 5)); + assertThat(created.getGender()).isEqualTo(commonly.commonlybe.human.entity.Gender.MALE); + } + + @Test + void 같은_사람이_여러_행에_있어도_인적사항은_한_번만_만든다() throws IOException { + givenUploadedExcel(List.of( + List.of("김철수", "1985-05-05", "남", "2020-01-01"), + List.of("김철수", "1985-05-05", "남", "2022-01-01"), + List.of("김철수", "1985-05-05", "남", "2024-01-01"))); + given(humanRepository.findByNameAndBirthDate("김철수", LocalDate.of(1985, 5, 5))) + .willReturn(Optional.empty()); + given(humanRepository.save(any())).willAnswer(invocation -> { + HumanEntity created = invocation.getArgument(0); + ReflectionTestUtils.setField(created, "humanId", NEW_HUMAN_ID); + return created; + }); + + MappingConfirmResponse response = mappingConfirmService.confirm(FILE_ID, confirmRequest()); + + assertThat(response.insertedCount()).isEqualTo(3); + assertThat(response.createdHumanCount()).isEqualTo(1); + verify(humanRepository, times(1)).save(any()); + verify(humanRepository, times(1)).findByNameAndBirthDate(any(), any()); + assertThat(captureSaved()).extracting(CertificateEntity::getHumanId) + .containsExactly(NEW_HUMAN_ID, NEW_HUMAN_ID, NEW_HUMAN_ID); + } + + @Test + void 이미_있는_인적사항은_엑셀_값으로_덮어쓰지_않는다() throws IOException { + // 엑셀 성별이 humans와 달라도 humans를 따른다. 검수된 데이터가 우선이다. + givenUploadedExcel(List.of(List.of("홍길동", "1990-01-01", "여", "2020-01-01"))); + given(humanRepository.findByNameAndBirthDate("홍길동", BIRTH_DATE)).willReturn(Optional.of(human())); + + MappingConfirmResponse response = mappingConfirmService.confirm(FILE_ID, confirmRequest()); + + assertThat(response.createdHumanCount()).isZero(); + verify(humanRepository, never()).save(any()); + assertThat(captureSaved()).singleElement() + .satisfies(certificate -> assertThat(certificate.getGender()).isEqualTo(Gender.MALE)); + } + + @Test + void 주소_열을_매핑하면_새로_만든_인적사항에_주소가_들어간다() throws IOException { + givenUploadedExcel(HEADERS_WITH_ADDRESS, List.of( + List.of("김철수", "1985-05-05", "남", "2020-01-01", "대전 유성구 대학로 1"))); + given(humanRepository.findByNameAndBirthDate("김철수", LocalDate.of(1985, 5, 5))) + .willReturn(Optional.empty()); + given(humanRepository.save(any())).willAnswer(invocation -> { + HumanEntity created = invocation.getArgument(0); + ReflectionTestUtils.setField(created, "humanId", NEW_HUMAN_ID); + return created; + }); + + MappingConfirmRequest request = new MappingConfirmRequest(List.of( + new ColumnMapping("성명", "name"), + new ColumnMapping("생년월일", "birthDate"), + new ColumnMapping("성별", "gender"), + new ColumnMapping("채용일", "hireDate"), + new ColumnMapping("주소", "address")), true); + + mappingConfirmService.confirm(FILE_ID, request); + + assertThat(captureSavedHuman().getAddress()).isEqualTo("대전 유성구 대학로 1"); + } + + @Test + void 주소_열이_없으면_주소는_null로_두고_만든다() throws IOException { + // 틀린 주소를 증명서에 인쇄하는 것보다 공란이 낫다 + givenUploadedExcel(List.of(List.of("김철수", "1985-05-05", "남", "2020-01-01"))); + given(humanRepository.findByNameAndBirthDate("김철수", LocalDate.of(1985, 5, 5))) + .willReturn(Optional.empty()); + given(humanRepository.save(any())).willAnswer(invocation -> { + HumanEntity created = invocation.getArgument(0); + ReflectionTestUtils.setField(created, "humanId", NEW_HUMAN_ID); + return created; + }); + + mappingConfirmService.confirm(FILE_ID, confirmRequest()); + + assertThat(captureSavedHuman().getAddress()).isNull(); } @Test @@ -272,6 +371,12 @@ private List captureSaved() { return saved.getValue(); } + private HumanEntity captureSavedHuman() { + ArgumentCaptor captor = ArgumentCaptor.forClass(HumanEntity.class); + verify(humanRepository).save(captor.capture()); + return captor.getValue(); + } + private HumanEntity human() { HumanEntity human = HumanEntity.builder() .name("홍길동")