-
Notifications
You must be signed in to change notification settings - Fork 0
feature: 경력증명서(certificate) 도메인 구현 (#16) #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feature/16-\uACBD\uB825\uC99D\uBA85\uC11Ccertificate-\uB3C4\uBA54\uC778-\uAD6C\uD604"
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package commonly.commonlybe.certificate.controller; | ||
|
|
||
| import commonly.commonlybe.certificate.controller.dto.CertificateDetailResponse; | ||
| import commonly.commonlybe.certificate.controller.dto.CertificateIssueRequest; | ||
| import commonly.commonlybe.certificate.controller.dto.CertificateIssueResponse; | ||
| import commonly.commonlybe.certificate.controller.dto.CertificateUpdateRequest; | ||
| import commonly.commonlybe.certificate.service.CertificateIssueService; | ||
| import commonly.commonlybe.certificate.service.CertificateService; | ||
| import jakarta.validation.Valid; | ||
| import java.nio.charset.StandardCharsets; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.core.io.ByteArrayResource; | ||
| import org.springframework.core.io.Resource; | ||
| import org.springframework.http.ContentDisposition; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.PutMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.ResponseStatus; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/certificates") | ||
| @RequiredArgsConstructor | ||
| public class CertificateController { | ||
|
|
||
| private final CertificateIssueService certificateIssueService; | ||
| private final CertificateService certificateService; | ||
|
|
||
| @PostMapping | ||
| @ResponseStatus(HttpStatus.CREATED) | ||
| public CertificateIssueResponse issue(@RequestBody @Valid CertificateIssueRequest request) { | ||
| return certificateIssueService.issue(request); | ||
| } | ||
|
|
||
| @GetMapping("/{certificateId}") | ||
| public CertificateDetailResponse findOne(@PathVariable Long certificateId) { | ||
| return certificateService.findIssued(certificateId); | ||
| } | ||
|
|
||
| @PutMapping("/{certificateId}") | ||
| @ResponseStatus(HttpStatus.NO_CONTENT) | ||
| public void update(@PathVariable Long certificateId, | ||
| @RequestBody @Valid CertificateUpdateRequest request) { | ||
| certificateService.update(certificateId, request); | ||
| } | ||
|
|
||
| @GetMapping("/{certificateId}/download") | ||
| public ResponseEntity<Resource> download(@PathVariable Long certificateId) { | ||
| CertificateService.IssuedFile file = certificateService.download(certificateId); | ||
| // 문서번호에 한글이 들어간다. filename*(RFC 5987)이 없으면 브라우저가 깨진 이름으로 저장한다. | ||
| ContentDisposition disposition = ContentDisposition.attachment() | ||
| .filename(file.fileName(), StandardCharsets.UTF_8) | ||
| .build(); | ||
| return ResponseEntity.ok() | ||
| .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) | ||
| .contentType(MediaType.APPLICATION_PDF) | ||
| .body(new ByteArrayResource(file.content())); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package commonly.commonlybe.certificate.controller; | ||
|
|
||
| import commonly.commonlybe.certificate.controller.dto.CertificateItemDto; | ||
| import commonly.commonlybe.certificate.service.CertificateService; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| /** | ||
| * /api/certificates/{certificateId}와 경로가 겹치지 않도록 humans 하위로 뺐다. | ||
| * 둘 다 /api/certificates/{x}면 Spring이 시작 시 Ambiguous mapping으로 죽는다. | ||
| */ | ||
| @RestController | ||
| @RequestMapping("/api/humans/{humanId}/certificates") | ||
| @RequiredArgsConstructor | ||
| public class HumanCertificateController { | ||
|
|
||
| private final CertificateService certificateService; | ||
|
|
||
| @GetMapping | ||
| public List<CertificateItemDto> findAllByHuman(@PathVariable Long humanId) { | ||
| return certificateService.findAllByHuman(humanId); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package commonly.commonlybe.certificate.controller.dto; | ||
|
|
||
| import commonly.commonlybe.certificate.entity.CertificateIssuedEntity; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| public record CertificateDetailResponse( | ||
| Long certificateId, | ||
| String documentNo, | ||
| LocalDateTime issuedAt, | ||
| String purpose, | ||
| String otherMatters, | ||
| CertificateHumanDto human, | ||
| int totalMonths, | ||
| int totalDays, | ||
| List<CertificateItemDto> items | ||
| ) { | ||
| public static CertificateDetailResponse of(CertificateIssuedEntity issued, | ||
| CertificateHumanDto human, | ||
| List<CertificateItemDto> items) { | ||
| return new CertificateDetailResponse( | ||
| issued.getCertificateIssuedId(), | ||
| issued.getDocumentNo(), | ||
| issued.getIssuedAt(), | ||
| issued.getPurpose(), | ||
| issued.getOtherMatters(), | ||
| human, | ||
| issued.getTotalMonths(), | ||
| issued.getTotalDays(), | ||
| items); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package commonly.commonlybe.certificate.controller.dto; | ||
|
|
||
| import commonly.commonlybe.human.entity.Gender; | ||
| import commonly.commonlybe.human.entity.HumanEntity; | ||
| import java.time.LocalDate; | ||
|
|
||
| public record CertificateHumanDto( | ||
| Long humanId, | ||
| String name, | ||
| LocalDate birthDate, | ||
| Gender gender, | ||
| String address | ||
| ) { | ||
| public static CertificateHumanDto from(HumanEntity human) { | ||
| return new CertificateHumanDto(human.getHumanId(), human.getName(), | ||
| human.getBirthDate(), human.getGender(), human.getAddress()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package commonly.commonlybe.certificate.controller.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotEmpty; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 재직사항 표가 10행 고정이라 certificateIds는 10개까지다. | ||
| * 넘치면 조용히 앞 10개만 찍는 대신 400으로 막는다. | ||
| */ | ||
| public record CertificateIssueRequest( | ||
| @NotNull Long humanId, | ||
| @NotEmpty @Size(max = 10) List<Long> certificateIds, | ||
| @NotBlank @Size(max = 255) String purpose, | ||
| @Size(max = 1000) String otherMatters | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package commonly.commonlybe.certificate.controller.dto; | ||
|
|
||
| public record CertificateIssueResponse( | ||
| Long certificateId, | ||
| String documentNo, | ||
| String downloadUrl | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package commonly.commonlybe.certificate.controller.dto; | ||
|
|
||
| import commonly.commonlybe.certificate.entity.CertificateEntity; | ||
| import java.time.LocalDate; | ||
|
|
||
| /** 재직 이력 한 줄. 발급 상세(§5.3)와 경력 사항 찾기(§5.4)가 같은 모양을 쓴다. */ | ||
| public record CertificateItemDto( | ||
| Long certificateId, | ||
| String division, | ||
| String department, | ||
| String employmentType, | ||
| String jobTitle, | ||
| String keyResponsibilities, | ||
| LocalDate hireDate, | ||
| LocalDate retirementDate, | ||
| LocalDate expirationDate, | ||
| String reason, | ||
| String note | ||
| ) { | ||
| public static CertificateItemDto from(CertificateEntity certificate) { | ||
| return new CertificateItemDto( | ||
| certificate.getCertificateId(), | ||
| certificate.getDivision(), | ||
| certificate.getDepartment(), | ||
| certificate.getEmploymentType(), | ||
| certificate.getJobTitle(), | ||
| certificate.getKeyResponsibilities(), | ||
| certificate.getHireDate(), | ||
| certificate.getRetirementDate(), | ||
| certificate.getExpirationDate(), | ||
| certificate.getReason(), | ||
| certificate.getNote()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package commonly.commonlybe.certificate.controller.dto; | ||
|
|
||
| import commonly.commonlybe.certificate.entity.CertificateCodes; | ||
| import commonly.commonlybe.certificate.entity.Gender; | ||
| import jakarta.validation.constraints.AssertTrue; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
| import java.time.LocalDate; | ||
|
|
||
| /** | ||
| * 재직 이력(certificate) 한 줄 수정. 이미 발급된 증명서 PDF는 불변이라 여기서 바꿔도 안 바뀐다. | ||
| */ | ||
| public record CertificateUpdateRequest( | ||
| @NotBlank @Size(max = 255) String name, | ||
| LocalDate birthDate, | ||
| @NotNull Gender gender, | ||
| @Size(max = 255) String jobTitle, | ||
| @Size(max = 255) String keyResponsibilities, | ||
| LocalDate hireDate, | ||
| LocalDate expirationDate, | ||
| LocalDate retirementDate, | ||
| String division, | ||
| @Size(max = 255) String department, | ||
| String reason, | ||
| String employmentType, | ||
| String note | ||
| ) { | ||
| @AssertTrue(message = "구분 값은 채용/전보/해지/퇴직 중 하나여야 합니다.") | ||
| public boolean isDivisionValid() { | ||
| return division == null || CertificateCodes.VALID_DIVISIONS.contains(division); | ||
| } | ||
|
|
||
| @AssertTrue(message = "근무형태 값은 기간제/단시간근로자 중 하나여야 합니다.") | ||
| public boolean isEmploymentTypeValid() { | ||
| return employmentType == null || CertificateCodes.VALID_EMPLOYMENT_TYPES.contains(employmentType); | ||
| } | ||
|
|
||
| @AssertTrue(message = "채용일이 퇴직일보다 늦습니다.") | ||
| public boolean isWorkPeriodValid() { | ||
| return hireDate == null || retirementDate == null || !hireDate.isAfter(retirementDate); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package commonly.commonlybe.certificate.document; | ||
|
|
||
| import jakarta.persistence.EntityManager; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Propagation; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class DocumentNumberGenerator { | ||
|
|
||
| /** | ||
| * 한 문장이라 원자적이다. 연도 행이 없으면 1부터 시작한다. | ||
| * Postgres 시퀀스를 안 쓴 이유: 연도별로 000001부터 다시 시작해야 하는데 시퀀스는 연초 리셋이 필요하다. | ||
| * | ||
| * ponytail: 연도 행 하나에 걸리는 행 잠금이라 동시 발급이 직렬화된다. | ||
| * 구청 창구 발급량에선 문제없음. 초당 수백 건이 되면 연도+구간 분할로 올린다. | ||
| */ | ||
| private static final String NEXT_NUMBER_SQL = """ | ||
| insert into document_number_seq (year, last_no) values (:year, 1) | ||
| on conflict (year) do update set last_no = document_number_seq.last_no + 1 | ||
| returning last_no | ||
| """; | ||
|
|
||
| private final EntityManager entityManager; | ||
|
|
||
| /** 발급 트랜잭션 안에서만 부른다. 밖에서 부르면 발급이 실패해도 번호가 빠진다. */ | ||
| @Transactional(propagation = Propagation.MANDATORY) | ||
| public String generate(int year) { | ||
| Number lastNo = (Number) entityManager.createNativeQuery(NEXT_NUMBER_SQL) | ||
| .setParameter("year", year) | ||
| .getSingleResult(); | ||
| return "유성구-%d-%06d".formatted(year, lastNo.longValue()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package commonly.commonlybe.certificate.document; | ||
|
|
||
| /** 서식의 "총 개월 일" 칸. 년 단위 칸이 없어 개월 + 일로만 낸다. */ | ||
| public record WorkPeriod(int months, int days) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package commonly.commonlybe.certificate.document; | ||
|
|
||
| import commonly.commonlybe.certificate.entity.CertificateEntity; | ||
| import java.time.LocalDate; | ||
| import java.time.temporal.ChronoUnit; | ||
| import java.util.List; | ||
|
|
||
| public final class WorkPeriodCalculator { | ||
|
|
||
| /** | ||
| * 경력 합산 관례: 각 구간을 일수로 환산해 더한 뒤 개월/일로 되돌린다. | ||
| * Period끼리 더하면 정규화가 안 돼 "1개월 45일" 같은 값이 나온다. | ||
| * | ||
| * ponytail: 1개월 = 30일로 고정. 달마다 길이가 다른데 일수 합계에는 기준일이 없어 | ||
| * 역산할 방법이 없다. 유성구청이 다른 산정 기준(민법 기간계산 등)을 쓰면 여기만 고친다. | ||
| */ | ||
| private static final int DAYS_PER_MONTH = 30; | ||
|
Comment on lines
+14
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
cat -n src/main/java/commonly/commonlybe/certificate/document/WorkPeriodCalculator.java
printf '%s\n' '--- directly related documentation and references ---'
rg -n -S -i 'Yuseong|유성구|work.?period|근무.?기간|30일|DAYS_PER_MONTH|산정 기준|산정기준' \
--glob '!target/**' --glob '!build/**' --glob '!node_modules/**' .Repository: DSM2026-Commonly/Commonly-Backend Length of output: 10273 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- documented calculation rule ---'
sed -n '183,200p' docs/certificate-domain.md
printf '%s\n' '--- issuance path ---'
cat -n src/main/java/commonly/commonlybe/certificate/service/CertificateIssueService.java | sed -n '35,58p'
printf '%s\n' '--- calculator boundary tests ---'
cat -n src/test/java/commonly/commonlybe/certificate/document/WorkPeriodCalculatorTest.java | sed -n '1,85p'Repository: DSM2026-Commonly/Commonly-Backend Length of output: 5754 Confirm the approved work-duration rule before issuing certificates.
🤖 Prompt for AI Agents |
||
|
|
||
| private WorkPeriodCalculator() { | ||
| } | ||
|
|
||
| public static WorkPeriod totalOf(List<CertificateEntity> certificates) { | ||
| long totalDays = certificates.stream() | ||
| .mapToLong(certificate -> daysOf(certificate.getHireDate(), certificate.workEndDate())) | ||
| .sum(); | ||
| return new WorkPeriod((int) (totalDays / DAYS_PER_MONTH), (int) (totalDays % DAYS_PER_MONTH)); | ||
| } | ||
|
|
||
| /** | ||
| * 재직일수는 양끝 포함(2020-01-01 ~ 2020-01-01 = 1일). | ||
| * 시작일이나 종료일이 없으면 그 구간은 총계에서 뺀다. 발급일까지로 임의 연장하지 않는다. | ||
| * 종료일이 시작일보다 앞서면(데이터 오류) 음수를 더하지 않고 0으로 본다. | ||
| */ | ||
| private static long daysOf(LocalDate from, LocalDate to) { | ||
| if (from == null || to == null || to.isBefore(from)) { | ||
| return 0; | ||
| } | ||
| return ChronoUnit.DAYS.between(from, to) + 1; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package commonly.commonlybe.certificate.entity; | ||
|
|
||
| import java.util.Set; | ||
|
|
||
| /** | ||
| * 엑셀 적재(RowValidator)와 수정 API가 같은 허용값을 봐야 한다. | ||
| * 두 벌로 두면 엑셀로는 들어가는데 API로는 막히는 상태가 된다. | ||
| */ | ||
| public final class CertificateCodes { | ||
|
|
||
| public static final Set<String> VALID_DIVISIONS = Set.of("채용", "전보", "해지", "퇴직"); | ||
| public static final Set<String> VALID_EMPLOYMENT_TYPES = Set.of("기간제", "단시간근로자"); | ||
|
|
||
| private CertificateCodes() { | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Validate
expirationDatewhen it is the effective work end date.A request with
hireDate = 2026-01-02,expirationDate = 2026-01-01, andretirementDate = nullpasses this check. The documented fallback then usesexpirationDate, andWorkPeriodCalculatorexcludes the reversed interval as zero days. This persists an impossible work history and can issue a certificate with an incorrect total instead of returning 400.Proposed fix
public boolean isWorkPeriodValid() { - return hireDate == null || retirementDate == null || !hireDate.isAfter(retirementDate); + LocalDate workEndDate = retirementDate != null ? retirementDate : expirationDate; + return hireDate == null || workEndDate == null || !hireDate.isAfter(workEndDate); }📝 Committable suggestion
🤖 Prompt for AI Agents