Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/certificate-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 등록부터.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,12 @@

import java.util.List;

public record MappingConfirmResponse(boolean saved, int insertedCount, List<FailedRowDto> failedRows) {
/**
* @param createdHumanCount 엑셀에만 있던 대상자를 새로 만든 수.
* 성명 오타 한 글자가 별개 인물을 만들 수 있어 담당자가 확인할 수 있게 내려준다.
*/
public record MappingConfirmResponse(boolean saved,
int insertedCount,
int createdHumanCount,
List<FailedRowDto> failedRows) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
public final class ColumnMappingTable {

/**
* department는 선택이다. 부서 열이 없는 기존 운영 엑셀도 그대로 올라가야 하므로
* department와 address는 선택이다. 해당 열이 없는 기존 운영 엑셀도 그대로 올라가야 하므로
* 필수로 올리면 안 된다 (certificate-domain.md §1-2).
*
* address는 certificate가 아니라 humans로 들어간다 — 엑셀로 처음 등장한 대상자의
* 인적사항을 만들 때 쓴다. 매핑하지 않으면 증명서 서식의 주소 칸이 공란으로 찍힌다.
*/
private static final Set<String> VALID_TARGET_FIELDS = Set.of(
"name", "birthDate", "gender", "jobTitle", "keyResponsibilities",
"name", "birthDate", "gender", "address", "jobTitle", "keyResponsibilities",
"hireDate", "expirationDate", "retirementDate", "division", "department",
"reason", "employmentType", "note"
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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라 동시 요청 중 하나만 통과한다.
Expand All @@ -68,24 +68,30 @@ public MappingConfirmResponse confirm(Long fileId, MappingConfirmRequest request

List<CertificateEntity> toInsert = new ArrayList<>();
List<FailedRowDto> failedRows = new ArrayList<>();
// 한 사람의 이력이 여러 행에 걸치므로 (성명, 생년월일)당 한 번만 조회한다.
Map<HumanKey, Optional<HumanEntity>> humans = new HashMap<>();
// 한 사람의 이력이 여러 행에 걸치므로 (성명, 생년월일)당 한 번만 조회·생성한다.
Map<HumanKey, HumanEntity> humans = new HashMap<>();
int createdHumanCount = 0;

for (ParsedRow row : parsedExcel.rows()) {
Map<String, String> fieldValues = extractFieldValues(row, request.mappings(), columnIndex);
switch (RowValidator.validate(fieldValues)) {
case RowResult.Success success -> {
CertificateEntity certificate = success.certificate();
Optional<HumanEntity> 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 -> { }
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> HEADERS = List.of("성명", "생년월일", "성별", "채용일");
/** 부서 열은 FE가 나중에 추가한 열이다. 기존 서식에는 없어서 별도 헤더로 둔다 (#63). */
private static final List<String> HEADERS_WITH_DEPARTMENT =
List.of("성명", "생년월일", "성별", "채용일", "근무부서");
/** 주소는 certificate가 아니라 humans로 들어간다 (#81). */
private static final List<String> HEADERS_WITH_ADDRESS =
List.of("성명", "생년월일", "성별", "채용일", "주소");

@Mock
private FileRepository fileRepository;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -272,6 +371,12 @@ private List<CertificateEntity> captureSaved() {
return saved.getValue();
}

private HumanEntity captureSavedHuman() {
ArgumentCaptor<HumanEntity> captor = ArgumentCaptor.forClass(HumanEntity.class);
verify(humanRepository).save(captor.capture());
return captor.getValue();
}

private HumanEntity human() {
HumanEntity human = HumanEntity.builder()
.name("홍길동")
Expand Down
Loading