diff --git a/common/src/main/java/com/ntropy/common/client/IncomeAnalysisQueryClient.java b/common/src/main/java/com/ntropy/common/client/IncomeAnalysisQueryClient.java new file mode 100644 index 0000000..6df7199 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/client/IncomeAnalysisQueryClient.java @@ -0,0 +1,14 @@ +package com.ntropy.common.client; + +import java.time.YearMonth; + +import com.ntropy.common.dto.work.summary.MonthlyIncomeAnalysisSummary; + +/** work-service가 diagnosis-service에 제공하는 회원·연월별 소득분석 조회 계약. */ +public interface IncomeAnalysisQueryClient { + + MonthlyIncomeAnalysisSummary getMonthlyIncomeAnalysis( + Long userId, + YearMonth yearMonth + ); +} diff --git a/common/src/main/java/com/ntropy/common/client/JobCommandClient.java b/common/src/main/java/com/ntropy/common/client/JobCommandClient.java index 58b9e34..eae30d6 100644 --- a/common/src/main/java/com/ntropy/common/client/JobCommandClient.java +++ b/common/src/main/java/com/ntropy/common/client/JobCommandClient.java @@ -11,7 +11,9 @@ public interface JobCommandClient { Long registerJob(JobRegisterCommand command); - void updateJob(Long jobId, JobUpdateCommand command); + /** userId는 요청자 본인 확인용 - jobId가 그 사람 소유가 아니면 예외. */ + void updateJob(Long userId, Long jobId, JobUpdateCommand command); - void deactivateJob(Long jobId); + /** userId는 요청자 본인 확인용 - jobId가 그 사람 소유가 아니면 예외. */ + void deactivateJob(Long userId, Long jobId); } diff --git a/common/src/main/java/com/ntropy/common/client/WorkLogCommandClient.java b/common/src/main/java/com/ntropy/common/client/WorkLogCommandClient.java index b48e61a..d4f3f77 100644 --- a/common/src/main/java/com/ntropy/common/client/WorkLogCommandClient.java +++ b/common/src/main/java/com/ntropy/common/client/WorkLogCommandClient.java @@ -13,9 +13,12 @@ public interface WorkLogCommandClient { Long registerActual(WorkLogRegisterCommand command); - void editWorkLog(Long logId, WorkLogPatchCommand command); + /** userId는 요청자 본인 확인용 - logId가 그 사람 소유가 아니면 예외. */ + void editWorkLog(Long userId, Long logId, WorkLogPatchCommand command); - void confirmWorkLog(Long logId, WorkLogPatchCommand command); + /** userId는 요청자 본인 확인용 - logId가 그 사람 소유가 아니면 예외. */ + void confirmWorkLog(Long userId, Long logId, WorkLogPatchCommand command); - void deleteWorkLog(Long logId); + /** userId는 요청자 본인 확인용 - logId가 그 사람 소유가 아니면 예외. */ + void deleteWorkLog(Long userId, Long logId); } diff --git a/common/src/main/java/com/ntropy/common/dto/work/summary/EarnedDepositComparison.java b/common/src/main/java/com/ntropy/common/dto/work/summary/EarnedDepositComparison.java new file mode 100644 index 0000000..0312e3a --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/work/summary/EarnedDepositComparison.java @@ -0,0 +1,21 @@ +package com.ntropy.common.dto.work.summary; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 잡별 발생소득(확정 근무일지 기준)과 실입금소득(매칭된 입금 거래 기준) 비교. + * differenceAmount가 음수라고 해서 미지급을 의미하지는 않는다(다음 달 입금 가능성). + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class EarnedDepositComparison { + + private Long jobId; + private String jobName; + private Long earnedIncome; + private Long depositedIncome; + private Long differenceAmount; +} diff --git a/common/src/main/java/com/ntropy/common/dto/work/summary/JobFatigueSummary.java b/common/src/main/java/com/ntropy/common/dto/work/summary/JobFatigueSummary.java new file mode 100644 index 0000000..27eac8f --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/work/summary/JobFatigueSummary.java @@ -0,0 +1,19 @@ +package com.ntropy.common.dto.work.summary; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 월별 소득분석에서 잡별 피로도 집계. averageFatigue는 근무시간 가중평균이다. */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class JobFatigueSummary { + + private Long jobId; + private String jobName; + private Integer workDays; + private Long totalWorkMinutes; + private Double averageFatigue; + private Long latestFatigue; +} diff --git a/common/src/main/java/com/ntropy/common/dto/work/summary/JobIncomeSummary.java b/common/src/main/java/com/ntropy/common/dto/work/summary/JobIncomeSummary.java new file mode 100644 index 0000000..142969d --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/work/summary/JobIncomeSummary.java @@ -0,0 +1,18 @@ +package com.ntropy.common.dto.work.summary; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 월별 소득분석에서 잡별 소득 비중을 나타내는 DTO. */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class JobIncomeSummary { + + private Long jobId; + private String jobName; + private Long incomeAmount; + private Double incomeRatio; + private Integer transactionCount; +} diff --git a/common/src/main/java/com/ntropy/common/dto/work/summary/MonthlyIncomeAnalysisSummary.java b/common/src/main/java/com/ntropy/common/dto/work/summary/MonthlyIncomeAnalysisSummary.java new file mode 100644 index 0000000..4a42a3e --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/work/summary/MonthlyIncomeAnalysisSummary.java @@ -0,0 +1,42 @@ +package com.ntropy.common.dto.work.summary; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * work-service가 diagnosis-service/AI-service에 제공하는 회원·연월별 소득분석 결과. + * 필드가 많아 다른 summary DTO와 달리 Builder를 사용한다. + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MonthlyIncomeAnalysisSummary { + + private Long userId; + private YearMonth yearMonth; + private LocalDate asOfDate; + private Long totalIncome; + private Long unmatchedIncome; + private Long pendingSettlementIncome; + private Integer matchedTransactionCount; + private Integer unmatchedTransactionCount; + private Integer ambiguousTransactionCount; + private List jobIncomes; + private Long primaryJobId; + private String primaryJobName; + private Long previousMonthIncome; + private Long incomeChangeAmount; + private Double incomeChangeRate; + private Double incomeVolatility; + private List earnedDepositComparisons; + private List fatigueSummaries; + private LocalDateTime calculatedAt; +} diff --git a/services/bff-service/src/main/java/com/ntropy/bff/controller/work/JobController.java b/services/bff-service/src/main/java/com/ntropy/bff/controller/work/JobController.java index 0efb818..e3e38c8 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/controller/work/JobController.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/controller/work/JobController.java @@ -56,22 +56,31 @@ public ApiResponse getJobCandidates(@ApiParam(hidden = tr } @PostMapping - public ResponseEntity> createJob(@RequestBody JobCreateRequest request) { - Long jobId = jobCommandClient.registerJob(request.toCommand()); + public ResponseEntity> createJob( + @ApiParam(hidden = true) Authentication authentication, + @RequestBody JobCreateRequest request) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + Long jobId = jobCommandClient.registerJob(request.toCommand(userId)); ApiResponse body = ApiResponse.success(HttpStatus.CREATED.value(), "잡이 등록되었습니다.", new JobCreateResponse(jobId)); return ResponseEntity.status(HttpStatus.CREATED).body(body); } @PutMapping("/{jobId}") - public ApiResponse updateJob(@PathVariable Long jobId, @RequestBody JobUpdateRequest request) { - jobCommandClient.updateJob(jobId, request.toCommand()); + public ApiResponse updateJob( + @ApiParam(hidden = true) Authentication authentication, + @PathVariable Long jobId, @RequestBody JobUpdateRequest request) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + jobCommandClient.updateJob(userId, jobId, request.toCommand()); return ApiResponse.success(HttpStatus.OK.value(), "잡이 수정되었습니다.", null); } @PatchMapping("/{jobId}/deactivate") - public ApiResponse deactivateJob(@PathVariable Long jobId) { - jobCommandClient.deactivateJob(jobId); + public ApiResponse deactivateJob( + @ApiParam(hidden = true) Authentication authentication, + @PathVariable Long jobId) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + jobCommandClient.deactivateJob(userId, jobId); return ApiResponse.success(HttpStatus.OK.value(), "잡이 비활성화되었습니다.", null); } } diff --git a/services/bff-service/src/main/java/com/ntropy/bff/controller/work/WorkLogController.java b/services/bff-service/src/main/java/com/ntropy/bff/controller/work/WorkLogController.java index e87fab0..0dc5352 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/controller/work/WorkLogController.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/controller/work/WorkLogController.java @@ -2,6 +2,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PatchMapping; @@ -14,8 +15,10 @@ import com.ntropy.bff.dto.work.request.WorkLogPatchRequest; import com.ntropy.bff.dto.work.request.WorkLogRegisterRequest; import com.ntropy.bff.dto.work.response.WorkLogCreateResponse; +import com.ntropy.bff.security.AuthenticatedUserIdResolver; import com.ntropy.common.client.WorkLogCommandClient; +import io.swagger.annotations.ApiParam; import lombok.RequiredArgsConstructor; @RestController @@ -24,38 +27,54 @@ public class WorkLogController { private final WorkLogCommandClient workLogCommandClient; + private final AuthenticatedUserIdResolver authenticatedUserIdResolver; @PostMapping("/plan") - public ResponseEntity> registerPlan(@RequestBody WorkLogRegisterRequest request) { - Long workId = workLogCommandClient.registerPlan(request.toCommand()); + public ResponseEntity> registerPlan( + @ApiParam(hidden = true) Authentication authentication, + @RequestBody WorkLogRegisterRequest request) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + Long workId = workLogCommandClient.registerPlan(request.toCommand(userId)); ApiResponse body = ApiResponse.success(HttpStatus.CREATED.value(), "근무 계획이 등록되었습니다.", new WorkLogCreateResponse(workId)); return ResponseEntity.status(HttpStatus.CREATED).body(body); } @PostMapping("/actual") - public ResponseEntity> registerActual(@RequestBody WorkLogRegisterRequest request) { - Long workId = workLogCommandClient.registerActual(request.toCommand()); + public ResponseEntity> registerActual( + @ApiParam(hidden = true) Authentication authentication, + @RequestBody WorkLogRegisterRequest request) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + Long workId = workLogCommandClient.registerActual(request.toCommand(userId)); ApiResponse body = ApiResponse.success(HttpStatus.CREATED.value(), "근무일지가 등록되었습니다.", new WorkLogCreateResponse(workId)); return ResponseEntity.status(HttpStatus.CREATED).body(body); } @PatchMapping("/{workId}/edit") - public ApiResponse editWorkLog(@PathVariable Long workId, @RequestBody WorkLogPatchRequest request) { - workLogCommandClient.editWorkLog(workId, request.toCommand()); + public ApiResponse editWorkLog( + @ApiParam(hidden = true) Authentication authentication, + @PathVariable Long workId, @RequestBody WorkLogPatchRequest request) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + workLogCommandClient.editWorkLog(userId, workId, request.toCommand()); return ApiResponse.success(HttpStatus.OK.value(), "근무일지가 수정되었습니다.", null); } @PatchMapping("/{workId}/confirm") - public ApiResponse confirmWorkLog(@PathVariable Long workId, @RequestBody WorkLogPatchRequest request) { - workLogCommandClient.confirmWorkLog(workId, request.toCommand()); + public ApiResponse confirmWorkLog( + @ApiParam(hidden = true) Authentication authentication, + @PathVariable Long workId, @RequestBody WorkLogPatchRequest request) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + workLogCommandClient.confirmWorkLog(userId, workId, request.toCommand()); return ApiResponse.success(HttpStatus.OK.value(), "근무일지가 확정되었습니다.", null); } @DeleteMapping("/{workId}") - public ApiResponse deleteWorkLog(@PathVariable Long workId) { - workLogCommandClient.deleteWorkLog(workId); + public ApiResponse deleteWorkLog( + @ApiParam(hidden = true) Authentication authentication, + @PathVariable Long workId) { + Long userId = authenticatedUserIdResolver.resolve(authentication); + workLogCommandClient.deleteWorkLog(userId, workId); return ApiResponse.success(HttpStatus.OK.value(), "근무일지가 삭제되었습니다.", null); } } diff --git a/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/JobCreateRequest.java b/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/JobCreateRequest.java index 8067b57..52bb016 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/JobCreateRequest.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/JobCreateRequest.java @@ -13,7 +13,6 @@ @NoArgsConstructor public class JobCreateRequest { - private Long userId; private Long categoryId; private String jobName; private String settlementType; @@ -26,7 +25,7 @@ public class JobCreateRequest { private List platformIds; private List schedules; - public JobRegisterCommand toCommand() { + public JobRegisterCommand toCommand(Long userId) { List safeSchedules = schedules == null ? Collections.emptyList() : schedules; return new JobRegisterCommand( userId, diff --git a/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/WorkLogRegisterRequest.java b/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/WorkLogRegisterRequest.java index e30e2a9..b936b95 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/WorkLogRegisterRequest.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/dto/work/request/WorkLogRegisterRequest.java @@ -15,7 +15,6 @@ @NoArgsConstructor public class WorkLogRegisterRequest { - private Long userId; private Long jobId; private LocalDate workDate; private LocalTime startTime; @@ -23,7 +22,7 @@ public class WorkLogRegisterRequest { private Long taskCount; private Long fatigue; - public WorkLogRegisterCommand toCommand() { + public WorkLogRegisterCommand toCommand(Long userId) { return new WorkLogRegisterCommand(userId, jobId, workDate, startTime, endTime, taskCount, fatigue); } } diff --git a/services/work-service/src/main/java/com/ntropy/work/client/LocalIncomeAnalysisQueryClient.java b/services/work-service/src/main/java/com/ntropy/work/client/LocalIncomeAnalysisQueryClient.java new file mode 100644 index 0000000..2f44665 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/client/LocalIncomeAnalysisQueryClient.java @@ -0,0 +1,23 @@ +package com.ntropy.work.client; + +import java.time.YearMonth; + +import org.springframework.stereotype.Component; + +import com.ntropy.common.client.IncomeAnalysisQueryClient; +import com.ntropy.common.dto.work.summary.MonthlyIncomeAnalysisSummary; +import com.ntropy.work.service.IncomeAnalysisService; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LocalIncomeAnalysisQueryClient implements IncomeAnalysisQueryClient { + + private final IncomeAnalysisService incomeAnalysisService; + + @Override + public MonthlyIncomeAnalysisSummary getMonthlyIncomeAnalysis(Long userId, YearMonth yearMonth) { + return incomeAnalysisService.getMonthlyIncomeAnalysis(userId, yearMonth); + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/client/LocalJobCommandClient.java b/services/work-service/src/main/java/com/ntropy/work/client/LocalJobCommandClient.java index 299c677..d737138 100644 --- a/services/work-service/src/main/java/com/ntropy/work/client/LocalJobCommandClient.java +++ b/services/work-service/src/main/java/com/ntropy/work/client/LocalJobCommandClient.java @@ -52,7 +52,7 @@ public Long registerJob(JobRegisterCommand command) { } @Override - public void updateJob(Long jobId, JobUpdateCommand command) { + public void updateJob(Long userId, Long jobId, JobUpdateCommand command) { Job job = Job.builder() .jobId(jobId) .categoryId(command.getCategoryId()) @@ -66,12 +66,12 @@ public void updateJob(Long jobId, JobUpdateCommand command) { .baseFatigue(command.getBaseFatigue()) .build(); - jobService.updateJob(job, toSchedules(command.getSchedules())); + jobService.updateJob(userId, job, toSchedules(command.getSchedules())); } @Override - public void deactivateJob(Long jobId) { - jobService.deactivateJob(jobId); + public void deactivateJob(Long userId, Long jobId) { + jobService.deactivateJob(userId, jobId); } private List toSchedules(List commands) { diff --git a/services/work-service/src/main/java/com/ntropy/work/client/LocalWorkLogCommandClient.java b/services/work-service/src/main/java/com/ntropy/work/client/LocalWorkLogCommandClient.java index 219cc94..010cb9b 100644 --- a/services/work-service/src/main/java/com/ntropy/work/client/LocalWorkLogCommandClient.java +++ b/services/work-service/src/main/java/com/ntropy/work/client/LocalWorkLogCommandClient.java @@ -31,18 +31,18 @@ public Long registerActual(WorkLogRegisterCommand command) { } @Override - public void editWorkLog(Long logId, WorkLogPatchCommand command) { - workLogService.editWorkLog(logId, toPatch(command)); + public void editWorkLog(Long userId, Long logId, WorkLogPatchCommand command) { + workLogService.editWorkLog(userId, logId, toPatch(command)); } @Override - public void confirmWorkLog(Long logId, WorkLogPatchCommand command) { - workLogService.confirmWorkLog(logId, toPatch(command)); + public void confirmWorkLog(Long userId, Long logId, WorkLogPatchCommand command) { + workLogService.confirmWorkLog(userId, logId, toPatch(command)); } @Override - public void deleteWorkLog(Long logId) { - workLogService.deleteWorkLog(logId); + public void deleteWorkLog(Long userId, Long logId) { + workLogService.deleteWorkLog(userId, logId); } private WorkLog toWorkLog(WorkLogRegisterCommand command) { diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/entity/Settlement.java b/services/work-service/src/main/java/com/ntropy/work/domain/entity/Settlement.java index da76395..05361ea 100644 --- a/services/work-service/src/main/java/com/ntropy/work/domain/entity/Settlement.java +++ b/services/work-service/src/main/java/com/ntropy/work/domain/entity/Settlement.java @@ -3,6 +3,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import com.ntropy.work.domain.enums.SettlementMatchStatus; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,11 +19,15 @@ public class Settlement { private Long settlementId; + private Long userId; + private SettlementMatchStatus status; private Long jobId; private LocalDate periodStart; private LocalDate periodEnd; + private LocalDate depositDate; private Long expectedAmount; private Long actualAmount; + private Integer transactionCount; private Long accountTransactionId; private LocalDateTime matchedAt; } diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/enums/SettlementMatchStatus.java b/services/work-service/src/main/java/com/ntropy/work/domain/enums/SettlementMatchStatus.java new file mode 100644 index 0000000..c094cce --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/domain/enums/SettlementMatchStatus.java @@ -0,0 +1,10 @@ +package com.ntropy.work.domain.enums; + +/** + * SETTLEMENT 행이 입금 거래-잡 매칭에 성공했는지 여부. + * AMBIGUOUS(한 플랫폼에 잡 여러 개 매핑)는 발생하지 않는다고 가정하고 다루지 않는다. + */ +public enum SettlementMatchStatus { + MATCHED, + UNMATCHED +} diff --git a/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java b/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java new file mode 100644 index 0000000..0c06379 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/exception/WorkErrorCode.java @@ -0,0 +1,50 @@ +package com.ntropy.work.exception; + +import com.ntropy.common.exception.ServiceErrorCode; +import lombok.Getter; + +@Getter +public enum WorkErrorCode implements ServiceErrorCode { + JOB_NAME_REQUIRED(400, "job_name은 필수입니다."), + CATEGORY_ID_REQUIRED(400, "category_id는 필수입니다."), + SETTLEMENT_TYPE_REQUIRED(400, "settlement_type은 필수입니다."), + HOURLY_WAGE_REQUIRED(400, "HOURLY 정산 방식은 hourly_wage가 필수입니다."), + PER_TASK_FIELDS_REQUIRED(400, "PER_TASK 정산 방식은 per_task_wage와 task_per_hour가 모두 필수입니다."), + MONTHLY_WAGE_REQUIRED(400, "MONTHLY 정산 방식은 monthly_wage가 필수입니다."), + IS_REGULAR_REQUIRED(400, "is_regular는 필수입니다."), + BASE_FATIGUE_REQUIRED(400, "base_fatigue는 필수입니다."), + REGULAR_JOB_SCHEDULE_REQUIRED(400, "정기잡(is_regular=true)은 정기근무 스케줄이 최소 1개 필요합니다."), + NON_REGULAR_JOB_SCHEDULE_NOT_ALLOWED(400, "비정기잡(is_regular=false)에는 정기근무 스케줄을 등록할 수 없습니다."), + SCHEDULE_OVERLAP(409, "겹치는 정기근무 스케줄이 있습니다."), + JOB_NOT_FOUND(404, "존재하지 않는 잡입니다."), + JOB_ACCESS_DENIED(403, "본인 소유가 아닌 잡입니다."), + + WORK_LOG_USER_ID_REQUIRED(400, "user_id는 필수입니다."), + WORK_LOG_JOB_ID_REQUIRED(400, "job_id는 필수입니다."), + WORK_DATE_REQUIRED(400, "work_date는 필수입니다."), + WORK_TIME_REQUIRED(400, "start_time/end_time은 필수입니다."), + FATIGUE_REQUIRED_FOR_ACTUAL(400, "계획 외 등록은 fatigue가 필수입니다."), + WORK_LOG_TIME_OVERLAP(409, "해당 시간대에 이미 등록된 근무일지가 있습니다."), + TASK_COUNT_REQUIRED(400, "건별 정산 잡은 확정 시 task_count가 필요합니다."), + WORK_LOG_NOT_FOUND(404, "존재하지 않는 근무일지입니다."), + WORK_LOG_ACCESS_DENIED(403, "본인 소유가 아닌 근무일지입니다."), + INVALID_WORK_TIME_RANGE(400, "시작 시간과 종료 시간이 같을 수 없습니다."), + WORK_LOG_ALREADY_CONFIRMED(409, "이미 확정된 근무일지입니다."), + + CATEGORY_NOT_FOUND(404, "존재하지 않는 카테고리입니다."), + PLATFORM_NOT_FOUND(404, "존재하지 않는 플랫폼입니다."), + + SAVING_GOAL_INVALID_TARGET_AMOUNT(400, "target_amount는 0보다 커야 합니다."), + SAVING_GOAL_INVALID_LABOR_INTENSITY(400, "labor_intensity는 1~5 사이여야 합니다."), + SAVING_GOAL_ALREADY_EXISTS(409, "이미 등록된 저축 목표입니다."), + + JOB_PLATFORM_MAPPING_ALREADY_EXISTS(409, "이미 등록된 잡-플랫폼 매핑입니다."); + + private final int statusCode; + private final String message; + + WorkErrorCode(int statusCode, String message) { + this.statusCode = statusCode; + this.message = message; + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java b/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java index 9229d4b..95ad825 100644 --- a/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java +++ b/services/work-service/src/main/java/com/ntropy/work/mapper/SettlementMapper.java @@ -1,18 +1,28 @@ package com.ntropy.work.mapper; import java.time.LocalDate; +import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import com.ntropy.work.domain.entity.Settlement; +import com.ntropy.work.domain.enums.SettlementMatchStatus; @Mapper public interface SettlementMapper { void insert(Settlement settlement); - boolean existsByJobIdAndPeriod(@Param("jobId") Long jobId, - @Param("periodStart") LocalDate periodStart, - @Param("periodEnd") LocalDate periodEnd); + /** 이 거래(accountTransactionId)가 이미 MATCHED로 처리됐는지 - 배치 재실행 중복 방지용. */ + boolean existsByAccountTransactionId(@Param("accountTransactionId") Long accountTransactionId); + + boolean existsByUserIdAndStatusAndPeriod(@Param("userId") Long userId, + @Param("status") SettlementMatchStatus status, + @Param("periodStart") LocalDate periodStart, + @Param("periodEnd") LocalDate periodEnd); + + List findByUserIdAndDepositDateRange(@Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); } diff --git a/services/work-service/src/main/java/com/ntropy/work/service/CategoryService.java b/services/work-service/src/main/java/com/ntropy/work/service/CategoryService.java index 4bf468d..70e8ae6 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/CategoryService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/CategoryService.java @@ -4,7 +4,9 @@ import org.springframework.stereotype.Service; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Category; +import com.ntropy.work.exception.WorkErrorCode; import com.ntropy.work.mapper.CategoryMapper; import lombok.RequiredArgsConstructor; @@ -25,7 +27,7 @@ public List findAll() { public Category findById(Long categoryId) { Category category = categoryMapper.findById(categoryId); if (category == null) { - throw new IllegalArgumentException("존재하지 않는 카테고리입니다. categoryId=" + categoryId); + throw new ServiceException(WorkErrorCode.CATEGORY_NOT_FOUND, "categoryId=" + categoryId); } return category; } diff --git a/services/work-service/src/main/java/com/ntropy/work/service/IncomeAnalysisService.java b/services/work-service/src/main/java/com/ntropy/work/service/IncomeAnalysisService.java new file mode 100644 index 0000000..c092380 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/service/IncomeAnalysisService.java @@ -0,0 +1,312 @@ +package com.ntropy.work.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.ntropy.common.dto.work.summary.EarnedDepositComparison; +import com.ntropy.common.dto.work.summary.JobFatigueSummary; +import com.ntropy.common.dto.work.summary.JobIncomeSummary; +import com.ntropy.common.dto.work.summary.MonthlyIncomeAnalysisSummary; +import com.ntropy.work.domain.entity.Job; +import com.ntropy.work.domain.entity.Settlement; +import com.ntropy.work.domain.entity.WorkLog; +import com.ntropy.work.domain.enums.SettlementMatchStatus; +import com.ntropy.work.domain.enums.SettlementStatus; +import com.ntropy.work.mapper.JobMapper; +import com.ntropy.work.mapper.SettlementMapper; +import com.ntropy.work.mapper.WorkLogMapper; +import com.ntropy.work.util.WorkTimeUtils; + +import lombok.RequiredArgsConstructor; + +/** + * 회원·연월별 소득분석(재무진단 연계 §1~14 기준)을 계산한다. + * + *

SettlementService가 배치로 미리 만들어 둔 SETTLEMENT 행(MATCHED/UNMATCHED, deposit_date + * 기준)을 그대로 집계한다. 한 플랫폼에 회원 잡이 여러 개 매핑되는 경우(AMBIGUOUS)는 없다고 + * 가정하고 다루지 않으며, ambiguousTransactionCount는 항상 0으로 내려간다.

+ * + *

변동성(§9)만 예외적으로 특정 과거 월 조회가 실패해도 그 달을 제외하고 계산한다 + * (2개월 미만이면 null). 그 외 totalIncome/previousMonthIncome 등 핵심 지표는 실패를 + * 감추지 않고 예외를 그대로 전파한다(§12) — diagnosis-service는 이 경우 기존 정상 + * DIAGNOSIS_RESULT를 유지한다.

+ */ +@Service +@RequiredArgsConstructor +public class IncomeAnalysisService { + + private static final String STATUS_CONFIRMED = "CONFIRMED"; + private static final int VOLATILITY_MIN_VALID_MONTHS = 2; + + private final SettlementMapper settlementMapper; + private final JobMapper jobMapper; + private final WorkLogMapper workLogMapper; + + public MonthlyIncomeAnalysisSummary getMonthlyIncomeAnalysis(Long userId, YearMonth yearMonth) { + Map jobNames = jobMapper.findByUserId(userId).stream() + .collect(Collectors.toMap(Job::getJobId, Job::getJobName, (a, b) -> a)); + + // 이번 달 본체 조회/계산 실패는 그대로 전파한다 (§12) - diagnosis-service가 기존 스냅샷을 유지하도록. + MonthAggregate current = aggregateMonth(userId, yearMonth); + + // 전월 조회 실패는 이번 달 분석 전체를 실패시키지 않고 null로 구분한다 (§8). + // previousMonthIncome=null: 조회 실패, previousMonthIncome=0L: 실제 전월 소득 0원. + Long previousMonthIncome = fetchTotalIncomeSafely(userId, yearMonth.minusMonths(1)); + Long changeAmount = previousMonthIncome == null ? null : current.totalIncome() - previousMonthIncome; + Double changeRate = (previousMonthIncome == null || previousMonthIncome == 0) + ? null + : (double) changeAmount / previousMonthIncome; + + return MonthlyIncomeAnalysisSummary.builder() + .userId(userId) + .yearMonth(yearMonth) + .asOfDate(resolveAsOfDate(yearMonth)) + .totalIncome(current.totalIncome()) + .unmatchedIncome(current.unmatchedIncome()) + .pendingSettlementIncome(calculatePendingSettlementIncome(userId, yearMonth)) + .matchedTransactionCount(current.matchedCount()) + .unmatchedTransactionCount(current.unmatchedCount()) + .ambiguousTransactionCount(0) + .jobIncomes(buildJobIncomes(current, jobNames)) + .primaryJobId(resolvePrimaryJobId(current)) + .primaryJobName(resolvePrimaryJobName(current, jobNames)) + .previousMonthIncome(previousMonthIncome) + .incomeChangeAmount(changeAmount) + .incomeChangeRate(changeRate) + .incomeVolatility(calculateVolatility(current.totalIncome(), previousMonthIncome, userId, yearMonth)) + .earnedDepositComparisons(buildEarnedDepositComparisons(userId, yearMonth, current, jobNames)) + .fatigueSummaries(buildFatigueSummaries(userId, yearMonth, jobNames)) + .calculatedAt(LocalDateTime.now()) + .build(); + } + + /** 조회/계산 실패 시 null을 반환해 "실패"와 "실제 0원"을 구분한다. */ + private Long fetchTotalIncomeSafely(Long userId, YearMonth yearMonth) { + try { + return aggregateMonth(userId, yearMonth).totalIncome(); + } catch (RuntimeException e) { + return null; + } + } + + private LocalDate resolveAsOfDate(YearMonth yearMonth) { + return yearMonth.equals(YearMonth.now()) ? LocalDate.now() : yearMonth.atEndOfMonth(); + } + + // ---------- SETTLEMENT 집계 ---------- + + private MonthAggregate aggregateMonth(Long userId, YearMonth yearMonth) { + List settlements = settlementMapper.findByUserIdAndDepositDateRange( + userId, yearMonth.atDay(1), yearMonth.atEndOfMonth()); + + MonthAggregate aggregate = new MonthAggregate(); + for (Settlement settlement : settlements) { + aggregate.apply(settlement); + } + return aggregate; + } + + /** + * 확정(CONFIRMED)됐지만 아직 SETTLEMENT로 매칭되지 않은(=settlement_status가 COMPLETED가 + * 아닌) 근무일지의 예상소득 합계. 아직 입금이 안 됐거나 배치가 안 돌았을 뿐 소득 자체가 + * 없는 건 아니므로, 확정된 소득 실적과 별도로 참고용으로 제공한다. + */ + private Long calculatePendingSettlementIncome(Long userId, YearMonth yearMonth) { + return findWorkLogsInMonth(userId, yearMonth).stream() + .filter(log -> STATUS_CONFIRMED.equals(log.getStatus())) + .filter(log -> log.getSettlementStatus() != SettlementStatus.COMPLETED) + .mapToLong(log -> log.getEstimatedIncome() == null ? 0L : log.getEstimatedIncome()) + .sum(); + } + + // ---------- 잡별 소득 (§7) ---------- + + private List buildJobIncomes(MonthAggregate current, Map jobNames) { + List result = new ArrayList<>(); + for (Map.Entry entry : current.incomeByJob().entrySet()) { + Long jobId = entry.getKey(); + long amount = entry.getValue(); + double ratio = current.totalIncome() == 0 ? 0 : (double) amount / current.totalIncome(); + int count = current.transactionCountByJob().getOrDefault(jobId, 0); + result.add(new JobIncomeSummary(jobId, jobNames.get(jobId), amount, ratio, count)); + } + return result; + } + + /** 소득금액이 가장 큰 잡이 여러 개로 동률이면 임의로 고르지 않고 null을 반환한다(§7). */ + private Long resolvePrimaryJobId(MonthAggregate current) { + List topJobs = findTopIncomeJobs(current); + return topJobs.size() == 1 ? topJobs.get(0) : null; + } + + private String resolvePrimaryJobName(MonthAggregate current, Map jobNames) { + Long primaryJobId = resolvePrimaryJobId(current); + return primaryJobId == null ? null : jobNames.get(primaryJobId); + } + + private List findTopIncomeJobs(MonthAggregate current) { + Map incomeByJob = current.incomeByJob(); + if (incomeByJob.isEmpty()) { + return List.of(); + } + long maxAmount = incomeByJob.values().stream().mapToLong(Long::longValue).max().orElseThrow(); + return incomeByJob.entrySet().stream() + .filter(entry -> entry.getValue() == maxAmount) + .map(Map.Entry::getKey) + .toList(); + } + + // ---------- 소득 변동성 (§9) ---------- + + /** + * 대상 월을 포함한 최근 최대 3개월의 변동계수. 이번 달/전월 값은 이미 조회된 값을 재사용하고, + * 2개월 전만 추가로 안전 조회한다. 조회 실패한 달은 제외하며, 유효한 월이 2개 미만이거나 + * 평균소득이 0이면 null. + */ + private Double calculateVolatility(long currentMonthIncome, Long previousMonthIncome, + Long userId, YearMonth targetMonth) { + List monthlyIncomes = new ArrayList<>(); + monthlyIncomes.add(currentMonthIncome); + if (previousMonthIncome != null) { + monthlyIncomes.add(previousMonthIncome); + } + Long twoMonthsAgoIncome = fetchTotalIncomeSafely(userId, targetMonth.minusMonths(2)); + if (twoMonthsAgoIncome != null) { + monthlyIncomes.add(twoMonthsAgoIncome); + } + + if (monthlyIncomes.size() < VOLATILITY_MIN_VALID_MONTHS) { + return null; + } + double mean = monthlyIncomes.stream().mapToLong(Long::longValue).average().orElse(0); + if (mean == 0) { + return null; + } + double variance = monthlyIncomes.stream() + .mapToDouble(income -> Math.pow(income - mean, 2)) + .sum() / monthlyIncomes.size(); + return Math.sqrt(variance) / mean; + } + + // ---------- 발생소득 vs 실입금소득 (§10) ---------- + + private List buildEarnedDepositComparisons( + Long userId, YearMonth yearMonth, MonthAggregate current, Map jobNames) { + Map earnedByJob = new LinkedHashMap<>(); + for (WorkLog workLog : findWorkLogsInMonth(userId, yearMonth)) { + if (!STATUS_CONFIRMED.equals(workLog.getStatus())) { + continue; + } + long income = workLog.getEstimatedIncome() == null ? 0 : workLog.getEstimatedIncome(); + earnedByJob.merge(workLog.getJobId(), income, Long::sum); + } + + Set jobIds = new LinkedHashSet<>(); + jobIds.addAll(earnedByJob.keySet()); + jobIds.addAll(current.incomeByJob().keySet()); + + List result = new ArrayList<>(); + for (Long jobId : jobIds) { + long earned = earnedByJob.getOrDefault(jobId, 0L); + long deposited = current.incomeByJob().getOrDefault(jobId, 0L); + result.add(new EarnedDepositComparison(jobId, jobNames.get(jobId), earned, deposited, deposited - earned)); + } + return result; + } + + // ---------- 잡별 피로도 (§11) ---------- + + /** + * WorkLog.fatigue는 등록 시점(WorkLogService)에 이미 계획=기본피로도/확정=실제피로도로 + * 채워져 있으므로, 완료/미래 일정을 여기서 다시 구분하지 않고 그대로 가중평균한다. + */ + private List buildFatigueSummaries(Long userId, YearMonth yearMonth, Map jobNames) { + Map> byJob = findWorkLogsInMonth(userId, yearMonth).stream() + .collect(Collectors.groupingBy(WorkLog::getJobId, LinkedHashMap::new, Collectors.toList())); + + List result = new ArrayList<>(); + for (Map.Entry> entry : byJob.entrySet()) { + Long jobId = entry.getKey(); + List jobLogs = entry.getValue(); + + int workDays = (int) jobLogs.stream().map(WorkLog::getWorkDate).distinct().count(); + long totalMinutes = jobLogs.stream() + .mapToLong(log -> WorkTimeUtils.durationMinutes(log.getStartTime(), log.getEndTime())) + .sum(); + Double averageFatigue = totalMinutes == 0 ? null : jobLogs.stream() + .mapToDouble(log -> log.getFatigue() * WorkTimeUtils.durationMinutes(log.getStartTime(), log.getEndTime())) + .sum() / totalMinutes; + Long latestFatigue = jobLogs.stream() + .max(Comparator.comparing(WorkLog::getWorkDate)) + .map(WorkLog::getFatigue) + .orElse(null); + + result.add(new JobFatigueSummary(jobId, jobNames.get(jobId), workDays, totalMinutes, averageFatigue, latestFatigue)); + } + return result; + } + + private List findWorkLogsInMonth(Long userId, YearMonth yearMonth) { + return workLogMapper.findByUserIdAndDateRange(userId, yearMonth.atDay(1), yearMonth.atEndOfMonth()); + } + + // ---------- 월 집계 값객체 ---------- + + private static final class MonthAggregate { + private long totalIncome = 0; + private long unmatchedIncome = 0; + private int matchedCount = 0; + private int unmatchedCount = 0; + private final Map incomeByJob = new LinkedHashMap<>(); + private final Map transactionCountByJob = new LinkedHashMap<>(); + + private void apply(Settlement settlement) { + long amount = settlement.getActualAmount(); + int count = settlement.getTransactionCount() == null ? 1 : settlement.getTransactionCount(); + if (settlement.getStatus() == SettlementMatchStatus.MATCHED) { + totalIncome += amount; + matchedCount += count; + incomeByJob.merge(settlement.getJobId(), amount, Long::sum); + transactionCountByJob.merge(settlement.getJobId(), count, Integer::sum); + } else { + unmatchedIncome += amount; + unmatchedCount += count; + } + } + + private long totalIncome() { + return totalIncome; + } + + private long unmatchedIncome() { + return unmatchedIncome; + } + + private int matchedCount() { + return matchedCount; + } + + private int unmatchedCount() { + return unmatchedCount; + } + + private Map incomeByJob() { + return incomeByJob; + } + + private Map transactionCountByJob() { + return transactionCountByJob; + } + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/service/JobPlatformMappingService.java b/services/work-service/src/main/java/com/ntropy/work/service/JobPlatformMappingService.java index c5d3a8a..0ae0646 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/JobPlatformMappingService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/JobPlatformMappingService.java @@ -4,7 +4,9 @@ import org.springframework.stereotype.Service; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.JobPlatformMapping; +import com.ntropy.work.exception.WorkErrorCode; import com.ntropy.work.mapper.JobPlatformMappingMapper; import com.ntropy.work.mapper.PlatformMapper; @@ -23,13 +25,13 @@ public List findByJobId(Long jobId) { public JobPlatformMapping register(Long jobId, Long platformId) { if (platformMapper.findById(platformId) == null) { - throw new IllegalArgumentException("존재하지 않는 플랫폼입니다. platformId=" + platformId); + throw new ServiceException(WorkErrorCode.PLATFORM_NOT_FOUND, "platformId=" + platformId); } boolean alreadyMapped = jobPlatformMappingMapper.findByJobId(jobId).stream() .anyMatch(mapping -> mapping.getPlatformId().equals(platformId)); if (alreadyMapped) { - throw new IllegalArgumentException( - "이미 등록된 잡-플랫폼 매핑입니다. jobId=" + jobId + ", platformId=" + platformId); + throw new ServiceException(WorkErrorCode.JOB_PLATFORM_MAPPING_ALREADY_EXISTS, + "jobId=" + jobId + ", platformId=" + platformId); } JobPlatformMapping mapping = JobPlatformMapping.builder() diff --git a/services/work-service/src/main/java/com/ntropy/work/service/JobService.java b/services/work-service/src/main/java/com/ntropy/work/service/JobService.java index 737e5ce..db5d74d 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/JobService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/JobService.java @@ -9,8 +9,10 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.JobSchedule; +import com.ntropy.work.exception.WorkErrorCode; import com.ntropy.work.mapper.JobMapper; import com.ntropy.work.mapper.JobScheduleMapper; import com.ntropy.work.util.WorkTimeUtils; @@ -59,7 +61,7 @@ public Job registerJob(Job job, List schedules) { public Job findById(Long jobId) { Job job = jobMapper.findById(jobId); if (job == null) { - throw new IllegalArgumentException("존재하지 않는 잡입니다. jobId=" + jobId); + throw new ServiceException(WorkErrorCode.JOB_NOT_FOUND, "jobId=" + jobId); } return job; } @@ -76,11 +78,15 @@ public List findSchedulesByJobId(Long jobId) { * 잡 수정. 스케줄은 부분 수정이 아니라 전체 교체다 — 넘어온 리스트로 * 기존 스케줄을 다 지우고 새로 넣는다(PUT이 나머지 필드를 통째로 덮어쓰는 것과 동일한 규칙). * - * @param schedules 정기잡 아니면 null 또는 빈 리스트 + * @param requesterUserId 요청자 userId. 잡 소유자와 다르면 예외 + * @param schedules 정기잡 아니면 null 또는 빈 리스트 */ @Transactional - public Job updateJob(Job job, List schedules) { + public Job updateJob(Long requesterUserId, Job job, List schedules) { Job existing = findById(job.getJobId()); + verifyOwnership(requesterUserId, existing.getUserId(), job.getJobId()); + job.setUserId(existing.getUserId()); + validate(job); validateScheduleConsistency(job, schedules); validateScheduleOverlap(job.getUserId(), schedules, job.getJobId()); @@ -102,29 +108,37 @@ public Job updateJob(Job job, List schedules) { } @Transactional - public void deactivateJob(Long jobId) { + public void deactivateJob(Long requesterUserId, Long jobId) { Job job = findById(jobId); + verifyOwnership(requesterUserId, job.getUserId(), jobId); job.setIsActive(false); job.setUpdatedAt(LocalDateTime.now()); jobMapper.update(job); } + /** 요청자가 이 잡의 소유자가 아니면 예외를 던진다. */ + private void verifyOwnership(Long requesterUserId, Long ownerUserId, Long jobId) { + if (!ownerUserId.equals(requesterUserId)) { + throw new ServiceException(WorkErrorCode.JOB_ACCESS_DENIED, "jobId=" + jobId); + } + } + private void validate(Job job) { if (!StringUtils.hasText(job.getJobName())) { - throw new IllegalArgumentException("job_name은 필수입니다."); + throw new ServiceException(WorkErrorCode.JOB_NAME_REQUIRED); } if (job.getCategoryId() == null) { - throw new IllegalArgumentException("category_id는 필수입니다."); + throw new ServiceException(WorkErrorCode.CATEGORY_ID_REQUIRED); } if (job.getSettlementType() == null) { - throw new IllegalArgumentException("settlement_type은 필수입니다."); + throw new ServiceException(WorkErrorCode.SETTLEMENT_TYPE_REQUIRED); } validateSettlementFields(job); if (job.getIsRegular() == null) { - throw new IllegalArgumentException("is_regular는 필수입니다."); + throw new ServiceException(WorkErrorCode.IS_REGULAR_REQUIRED); } if (job.getBaseFatigue() == null) { - throw new IllegalArgumentException("base_fatigue는 필수입니다."); + throw new ServiceException(WorkErrorCode.BASE_FATIGUE_REQUIRED); } } @@ -135,17 +149,17 @@ private void validateSettlementFields(Job job) { switch (job.getSettlementType()) { case HOURLY: if (job.getHourlyWage() == null) { - throw new IllegalArgumentException("HOURLY 정산 방식은 hourly_wage가 필수입니다."); + throw new ServiceException(WorkErrorCode.HOURLY_WAGE_REQUIRED); } break; case PER_TASK: if (job.getPerTaskWage() == null || job.getTaskPerHour() == null) { - throw new IllegalArgumentException("PER_TASK 정산 방식은 per_task_wage와 task_per_hour가 모두 필수입니다."); + throw new ServiceException(WorkErrorCode.PER_TASK_FIELDS_REQUIRED); } break; case MONTHLY: if (job.getMonthlyWage() == null) { - throw new IllegalArgumentException("MONTHLY 정산 방식은 monthly_wage가 필수입니다."); + throw new ServiceException(WorkErrorCode.MONTHLY_WAGE_REQUIRED); } break; default: @@ -185,10 +199,10 @@ private Long calculateMonthlyExpectedIncome(Job job, List schedules private void validateScheduleConsistency(Job job, List schedules) { boolean hasSchedules = !safe(schedules).isEmpty(); if (Boolean.TRUE.equals(job.getIsRegular()) && !hasSchedules) { - throw new IllegalArgumentException("정기잡(is_regular=true)은 정기근무 스케줄이 최소 1개 필요합니다."); + throw new ServiceException(WorkErrorCode.REGULAR_JOB_SCHEDULE_REQUIRED); } if (Boolean.FALSE.equals(job.getIsRegular()) && hasSchedules) { - throw new IllegalArgumentException("비정기잡(is_regular=false)에는 정기근무 스케줄을 등록할 수 없습니다."); + throw new ServiceException(WorkErrorCode.NON_REGULAR_JOB_SCHEDULE_NOT_ALLOWED); } } @@ -220,8 +234,7 @@ private void validateScheduleOverlap(Long userId, List schedules, L } if (WorkTimeUtils.isOverlapping(newSchedule.getStartTime(), newSchedule.getEndTime(), other.getStartTime(), other.getEndTime())) { - throw new IllegalArgumentException( - "겹치는 정기근무 스케줄이 있습니다. dayOfWeek=" + newSchedule.getDayOfWeek()); + throw new ServiceException(WorkErrorCode.SCHEDULE_OVERLAP, "dayOfWeek=" + newSchedule.getDayOfWeek()); } } } diff --git a/services/work-service/src/main/java/com/ntropy/work/service/PlatformService.java b/services/work-service/src/main/java/com/ntropy/work/service/PlatformService.java index 48f2ecb..3207ed2 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/PlatformService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/PlatformService.java @@ -4,7 +4,9 @@ import org.springframework.stereotype.Service; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Platform; +import com.ntropy.work.exception.WorkErrorCode; import com.ntropy.work.mapper.PlatformMapper; import lombok.RequiredArgsConstructor; @@ -25,7 +27,7 @@ public List findAll() { public Platform findById(Long platformId) { Platform platform = platformMapper.findById(platformId); if (platform == null) { - throw new IllegalArgumentException("존재하지 않는 플랫폼입니다. platformId=" + platformId); + throw new ServiceException(WorkErrorCode.PLATFORM_NOT_FOUND, "platformId=" + platformId); } return platform; } diff --git a/services/work-service/src/main/java/com/ntropy/work/service/SavingGoalService.java b/services/work-service/src/main/java/com/ntropy/work/service/SavingGoalService.java index b0e48b1..c4b62d3 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/SavingGoalService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/SavingGoalService.java @@ -2,7 +2,9 @@ import org.springframework.stereotype.Service; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.SavingGoal; +import com.ntropy.work.exception.WorkErrorCode; import com.ntropy.work.mapper.SavingGoalMapper; import lombok.RequiredArgsConstructor; @@ -25,9 +27,8 @@ public SavingGoal registerSavingGoal(SavingGoal savingGoal) { SavingGoal existing = savingGoalMapper.findByUserIdAndTargetMonth( savingGoal.getUserId(), savingGoal.getTargetMonth()); if (existing != null) { - throw new IllegalArgumentException( - "이미 등록된 저축 목표입니다. userId=" + savingGoal.getUserId() - + ", targetMonth=" + savingGoal.getTargetMonth()); + throw new ServiceException(WorkErrorCode.SAVING_GOAL_ALREADY_EXISTS, + "userId=" + savingGoal.getUserId() + ", targetMonth=" + savingGoal.getTargetMonth()); } savingGoalMapper.insert(savingGoal); @@ -36,12 +37,11 @@ public SavingGoal registerSavingGoal(SavingGoal savingGoal) { private void validate(SavingGoal savingGoal) { if (savingGoal.getTargetAmount() == null || savingGoal.getTargetAmount() <= 0) { - throw new IllegalArgumentException("target_amount는 0보다 커야 합니다."); + throw new ServiceException(WorkErrorCode.SAVING_GOAL_INVALID_TARGET_AMOUNT); } Long laborIntensity = savingGoal.getLaborIntensity(); if (laborIntensity == null || laborIntensity < MIN_LABOR_INTENSITY || laborIntensity > MAX_LABOR_INTENSITY) { - throw new IllegalArgumentException("labor_intensity는 " + MIN_LABOR_INTENSITY - + "~" + MAX_LABOR_INTENSITY + " 사이여야 합니다."); + throw new ServiceException(WorkErrorCode.SAVING_GOAL_INVALID_LABOR_INTENSITY); } } } diff --git a/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java b/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java index 05710e7..4c6691a 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java @@ -16,6 +16,7 @@ import com.ntropy.work.domain.entity.Platform; import com.ntropy.work.domain.entity.Settlement; import com.ntropy.work.domain.entity.WorkLog; +import com.ntropy.work.domain.enums.SettlementMatchStatus; import com.ntropy.work.domain.enums.SettlementStatus; import com.ntropy.work.mapper.JobMapper; import com.ntropy.work.mapper.JobPlatformMappingMapper; @@ -28,7 +29,13 @@ /** * 입금 거래를 PLATFORM/JOB과 매칭해 SETTLEMENT를 생성하고, 해당 기간의 확정된(CONFIRMED) * WORK_LOG를 COMPLETED로 갱신한다. processDate 당일의 입금 거래만 확인하므로 매일 배치로 - * 호출하는 것을 전제로 한다. 이미 매칭된 job+기간은 SETTLEMENT UNIQUE 제약으로 재처리하지 않는다. + * 호출하는 것을 전제로 한다. 이미 같은 accountTransactionId로 처리된 거래는 재처리하지 + * 않는다 - 배치가 중복 실행돼도 안전하고, 같은 잡·같은 정산기간에 서로 다른 거래가 여러 건 + * 들어와도(거래 ID가 다르므로) 각각 정상적으로 반영된다. + * + *

매칭되지 않은(UNMATCHED) 거래도 버리지 않고, 같은 날짜에 들어온 것들을 합산해 + * status=UNMATCHED(job_id=null) 행 하나로 저장한다. 한 플랫폼에 회원 잡이 여러 개 + * 매핑되는 경우(AMBIGUOUS)는 없다고 가정하고 별도로 다루지 않는다.

*/ @Service @RequiredArgsConstructor @@ -51,39 +58,52 @@ public void processSettlement(Long userId, LocalDate processDate) { List platforms = platformMapper.findAll(); List jobs = jobMapper.findByUserId(userId); + long unmatchedAmount = 0; + int unmatchedCount = 0; for (NormalizedIncomingTransaction transaction : transactions) { - processTransaction(transaction, platforms, jobs); + if (!processMatchedTransaction(userId, transaction, platforms, jobs)) { + unmatchedAmount += transaction.amount().longValueExact(); + unmatchedCount++; + } + } + if (unmatchedCount > 0) { + saveUnmatchedSettlement(userId, processDate, unmatchedAmount, unmatchedCount); } } - private void processTransaction(NormalizedIncomingTransaction transaction, List platforms, - List jobs) { + /** 매칭에 성공해 SETTLEMENT를 만들었거나 이미 이 거래로 만들어져 있으면 true. */ + private boolean processMatchedTransaction(Long userId, NormalizedIncomingTransaction transaction, + List platforms, List jobs) { PlatformMatchResult result = PlatformMatcher.match(transaction.counterpartyName(), platforms); if (!(result instanceof PlatformMatchResult.Matched matched)) { - return; + return false; } Platform platform = matched.platform(); Long jobId = resolveJobId(platform.getPlatformId(), jobs); if (jobId == null) { - return; + return false; } - SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, transaction.transactionDate()); - if (settlementMapper.existsByJobIdAndPeriod(jobId, period.start(), period.end())) { - return; + if (settlementMapper.existsByAccountTransactionId(transaction.transactionId())) { + return true; } + SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, transaction.transactionDate()); List logsInPeriod = findConfirmedLogsInPeriod(jobId, period); long expectedAmount = logsInPeriod.stream() .mapToLong(log -> log.getEstimatedIncome() == null ? 0L : log.getEstimatedIncome()) .sum(); Settlement settlement = Settlement.builder() + .userId(userId) + .status(SettlementMatchStatus.MATCHED) .jobId(jobId) .periodStart(period.start()) .periodEnd(period.end()) + .depositDate(transaction.transactionDate()) .expectedAmount(expectedAmount) .actualAmount(transaction.amount().longValueExact()) + .transactionCount(1) .accountTransactionId(transaction.transactionId()) .matchedAt(LocalDateTime.now()) .build(); @@ -93,6 +113,34 @@ private void processTransaction(NormalizedIncomingTransaction transaction, List< log.setSettlementStatus(SettlementStatus.COMPLETED); workLogMapper.update(log); } + return true; + } + + /** + * 매칭되지 않은 거래는 잡을 특정할 수 없으니 job_id=null로, 같은 날짜 것들을 합쳐 + * 하루 단위 한 행으로 저장한다. 배치가 중복 실행돼도 같은 날짜에 두 번 쌓이지 않도록 + * UNMATCHED는 (user_id, status, period) 기준으로 존재 여부를 확인한다. + */ + private void saveUnmatchedSettlement(Long userId, LocalDate processDate, long amount, int count) { + if (settlementMapper.existsByUserIdAndStatusAndPeriod( + userId, SettlementMatchStatus.UNMATCHED, processDate, processDate)) { + return; + } + + Settlement settlement = Settlement.builder() + .userId(userId) + .status(SettlementMatchStatus.UNMATCHED) + .jobId(null) + .periodStart(processDate) + .periodEnd(processDate) + .depositDate(processDate) + .expectedAmount(0L) + .actualAmount(amount) + .transactionCount(count) + .accountTransactionId(null) + .matchedAt(LocalDateTime.now()) + .build(); + settlementMapper.insert(settlement); } private Long resolveJobId(Long platformId, List jobs) { diff --git a/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java b/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java index 5f52a6b..a18d72d 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/WorkLogService.java @@ -6,10 +6,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.WorkLog; import com.ntropy.work.domain.enums.SettlementStatus; import com.ntropy.work.domain.enums.SettlementType; +import com.ntropy.work.exception.WorkErrorCode; import com.ntropy.work.mapper.WorkLogMapper; import com.ntropy.work.util.WorkTimeUtils; @@ -70,10 +72,13 @@ public WorkLog registerActual(WorkLog workLog) { /** * 근무일지 수정. PLANNED/CONFIRMED 둘 다 가능하며 상태는 그대로 유지한다. * 넘어온 필드만 덮어쓰고 나머지는 기존 값을 유지한다. + * + * @param requesterUserId 요청자 userId. 근무일지 소유자와 다르면 예외 */ @Transactional - public WorkLog editWorkLog(Long logId, WorkLog patch) { + public WorkLog editWorkLog(Long requesterUserId, Long logId, WorkLog patch) { WorkLog existing = findById(logId); + verifyOwnership(requesterUserId, existing.getUserId(), logId); applyPatch(existing, patch); validateNoOverlap(existing.getUserId(), existing.getWorkDate(), existing.getStartTime(), existing.getEndTime(), existing.getLogId()); @@ -89,12 +94,15 @@ public WorkLog editWorkLog(Long logId, WorkLog patch) { /** * 근무일지 확정. PLANNED 상태에서만 가능하다. jobId/시간/건수/피로도를 전부 * 받아 덮어쓸 수 있고(사진 화면 기준), 최종적으로 CONFIRMED로 전환한다. + * + * @param requesterUserId 요청자 userId. 근무일지 소유자와 다르면 예외 */ @Transactional - public WorkLog confirmWorkLog(Long logId, WorkLog patch) { + public WorkLog confirmWorkLog(Long requesterUserId, Long logId, WorkLog patch) { WorkLog existing = findById(logId); + verifyOwnership(requesterUserId, existing.getUserId(), logId); if (STATUS_CONFIRMED.equals(existing.getStatus())) { - throw new IllegalStateException("이미 확정된 근무일지입니다. logId=" + logId); + throw new ServiceException(WorkErrorCode.WORK_LOG_ALREADY_CONFIRMED, "logId=" + logId); } applyPatch(existing, patch); validateNoOverlap(existing.getUserId(), existing.getWorkDate(), existing.getStartTime(), existing.getEndTime(), @@ -113,19 +121,27 @@ public WorkLog confirmWorkLog(Long logId, WorkLog patch) { } @Transactional - public void deleteWorkLog(Long logId) { - findById(logId); + public void deleteWorkLog(Long requesterUserId, Long logId) { + WorkLog existing = findById(logId); + verifyOwnership(requesterUserId, existing.getUserId(), logId); workLogMapper.deleteById(logId); } public WorkLog findById(Long logId) { WorkLog workLog = workLogMapper.findById(logId); if (workLog == null) { - throw new IllegalArgumentException("존재하지 않는 근무일지입니다. logId=" + logId); + throw new ServiceException(WorkErrorCode.WORK_LOG_NOT_FOUND, "logId=" + logId); } return workLog; } + /** 요청자가 이 근무일지의 소유자가 아니면 예외를 던진다. */ + private void verifyOwnership(Long requesterUserId, Long ownerUserId, Long logId) { + if (!ownerUserId.equals(requesterUserId)) { + throw new ServiceException(WorkErrorCode.WORK_LOG_ACCESS_DENIED, "logId=" + logId); + } + } + private void applyPatch(WorkLog existing, WorkLog patch) { if (patch.getJobId() != null) { existing.setJobId(patch.getJobId()); @@ -152,7 +168,7 @@ private Long calculateEstimatedIncome(Job job, LocalTime startTime, LocalTime en return null; } if (startTime.equals(endTime)) { - throw new IllegalArgumentException("시작 시간과 종료 시간이 같을 수 없습니다."); + throw new ServiceException(WorkErrorCode.INVALID_WORK_TIME_RANGE); } double hours = WorkTimeUtils.durationMinutes(startTime, endTime) / 60.0; @@ -185,37 +201,36 @@ private void validateNoOverlap(Long userId, LocalDate workDate, LocalTime startT continue; } if (WorkTimeUtils.isOverlapping(startTime, endTime, other.getStartTime(), other.getEndTime())) { - throw new IllegalArgumentException( - "해당 시간대에 이미 등록된 근무일지가 있습니다. workDate=" + workDate); + throw new ServiceException(WorkErrorCode.WORK_LOG_TIME_OVERLAP, "workDate=" + workDate); } } } private void validateTaskCountIfPerTask(Job job, Long taskCount) { if (SettlementType.PER_TASK.equals(job.getSettlementType()) && taskCount == null) { - throw new IllegalArgumentException("건별 정산 잡은 확정 시 task_count가 필요합니다."); + throw new ServiceException(WorkErrorCode.TASK_COUNT_REQUIRED); } } private void validatePlan(WorkLog workLog) { if (workLog.getUserId() == null) { - throw new IllegalArgumentException("user_id는 필수입니다."); + throw new ServiceException(WorkErrorCode.WORK_LOG_USER_ID_REQUIRED); } if (workLog.getJobId() == null) { - throw new IllegalArgumentException("job_id는 필수입니다."); + throw new ServiceException(WorkErrorCode.WORK_LOG_JOB_ID_REQUIRED); } if (workLog.getWorkDate() == null) { - throw new IllegalArgumentException("work_date는 필수입니다."); + throw new ServiceException(WorkErrorCode.WORK_DATE_REQUIRED); } if (workLog.getStartTime() == null || workLog.getEndTime() == null) { - throw new IllegalArgumentException("start_time/end_time은 필수입니다."); + throw new ServiceException(WorkErrorCode.WORK_TIME_REQUIRED); } } private void validateActual(WorkLog workLog) { validatePlan(workLog); if (workLog.getFatigue() == null) { - throw new IllegalArgumentException("계획 외 등록은 fatigue가 필수입니다."); + throw new ServiceException(WorkErrorCode.FATIGUE_REQUIRED_FOR_ACTUAL); } } } diff --git a/services/work-service/src/main/resources/db/settlement-add-deposit-date.sql b/services/work-service/src/main/resources/db/settlement-add-deposit-date.sql new file mode 100644 index 0000000..3c65cf7 --- /dev/null +++ b/services/work-service/src/main/resources/db/settlement-add-deposit-date.sql @@ -0,0 +1,10 @@ +-- ============================================================ +-- SETTLEMENT.deposit_date 추가 +-- 입금 거래가 실제로 발생한 날짜(ACCOUNT_TRANSACTION.transaction_date)를 저장한다. +-- period_start/period_end는 WORK_LOG 기준 근무기간이라 정산주기(MONTHLY 등)에 따라 +-- 입금 월과 달라질 수 있어, "몇 월에 입금됐는지"를 구분하려면 별도 컬럼이 필요하다. +-- 이미 배포된 스키마에 대한 증분 추가라 settlement-add-table.sql을 직접 고치지 않는다. +-- ============================================================ + +ALTER TABLE `SETTLEMENT` + ADD COLUMN `deposit_date` DATE NOT NULL COMMENT '입금 거래일 (월별 집계 기준)' AFTER `period_end`; diff --git a/services/work-service/src/main/resources/db/settlement-add-unmatched-status.sql b/services/work-service/src/main/resources/db/settlement-add-unmatched-status.sql new file mode 100644 index 0000000..d52b8c9 --- /dev/null +++ b/services/work-service/src/main/resources/db/settlement-add-unmatched-status.sql @@ -0,0 +1,33 @@ +-- ============================================================ +-- SETTLEMENT에 UNMATCHED 집계 지원 컬럼 추가 +-- - user_id: job_id가 NULL인 UNMATCHED 행은 JOB 조인으로 사용자를 알 수 없어 직접 저장 +-- - status: MATCHED/UNMATCHED 구분 (기존 행은 전부 MATCHED로 백필) +-- - transaction_count: UNMATCHED는 하루치 미매칭 거래를 합산해 저장하므로 몇 건이 +-- 합쳐졌는지 별도로 알아야 한다 (MATCHED는 거래 1건=행 1개라 항상 1). +-- - job_id/account_transaction_id: UNMATCHED 행은 특정 잡/거래를 가리킬 수 없어 nullable로 변경 +-- 이미 배포된 스키마에 대한 증분 추가. +-- ============================================================ + +ALTER TABLE `SETTLEMENT` + ADD COLUMN `user_id` BIGINT NULL COMMENT '사용자 ID (job_id가 NULL인 UNMATCHED 행 식별용)' AFTER `settlement_id`; + +UPDATE `SETTLEMENT` s +JOIN `JOB` j ON s.job_id = j.job_id +SET s.user_id = j.user_id; + +ALTER TABLE `SETTLEMENT` + MODIFY COLUMN `user_id` BIGINT NOT NULL; + +ALTER TABLE `SETTLEMENT` + ADD COLUMN `status` VARCHAR(20) NOT NULL DEFAULT 'MATCHED' COMMENT 'MATCHED/UNMATCHED' AFTER `user_id`; + +ALTER TABLE `SETTLEMENT` + ADD COLUMN `transaction_count` INT NOT NULL DEFAULT 1 COMMENT '이 행에 합산된 거래 건수 (MATCHED는 항상 1)' AFTER `actual_amount`; + +ALTER TABLE `SETTLEMENT` + MODIFY COLUMN `job_id` BIGINT NULL; + +ALTER TABLE `SETTLEMENT` + MODIFY COLUMN `account_transaction_id` BIGINT NULL; + +CREATE INDEX `IDX_SETTLEMENT_USER_ID` ON `SETTLEMENT` (`user_id`); diff --git a/services/work-service/src/main/resources/db/settlement-fix-duplicate-check-key.sql b/services/work-service/src/main/resources/db/settlement-fix-duplicate-check-key.sql new file mode 100644 index 0000000..1e1377e --- /dev/null +++ b/services/work-service/src/main/resources/db/settlement-fix-duplicate-check-key.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- SETTLEMENT 중복 방지 기준을 (job_id, period_start, period_end)에서 +-- account_transaction_id로 교체. +-- 기존 UK_SETTLEMENT_JOB_PERIOD는 "같은 잡·같은 정산기간에 서로 다른 거래 +-- 2건이 들어오는" 정상 케이스까지 막아버리는 문제가 있었다. 진짜 막아야 +-- 하는 건 "같은 거래(transactionId)를 두 번 처리하는 것"이므로 이 기준으로 +-- 바꾼다. UNMATCHED 행은 account_transaction_id가 NULL인데, MySQL UNIQUE +-- 인덱스는 NULL을 서로 다른 값으로 취급하므로 여러 UNMATCHED 행이 있어도 +-- 이 제약에 걸리지 않는다. +-- ============================================================ + +ALTER TABLE `SETTLEMENT` DROP INDEX `UK_SETTLEMENT_JOB_PERIOD`; + +ALTER TABLE `SETTLEMENT` ADD CONSTRAINT `UK_SETTLEMENT_ACCOUNT_TRANSACTION_ID` UNIQUE ( + `account_transaction_id` +); diff --git a/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml b/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml index 4476b65..59f1628 100644 --- a/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml +++ b/services/work-service/src/main/resources/mapper/work/SettlementMapper.xml @@ -5,19 +5,33 @@ INSERT INTO SETTLEMENT - (job_id, period_start, period_end, expected_amount, actual_amount, - account_transaction_id, matched_at) + (user_id, status, job_id, period_start, period_end, deposit_date, expected_amount, actual_amount, + transaction_count, account_transaction_id, matched_at) VALUES - (#{jobId}, #{periodStart}, #{periodEnd}, #{expectedAmount}, #{actualAmount}, - #{accountTransactionId}, #{matchedAt}) + (#{userId}, #{status}, #{jobId}, #{periodStart}, #{periodEnd}, #{depositDate}, #{expectedAmount}, + #{actualAmount}, #{transactionCount}, #{accountTransactionId}, #{matchedAt}) - SELECT COUNT(*) > 0 FROM SETTLEMENT - WHERE job_id = #{jobId} + WHERE account_transaction_id = #{accountTransactionId} + + + + + diff --git a/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java b/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java index e07911d..d8df62a 100644 --- a/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java +++ b/services/work-service/src/test/java/com/ntropy/work/mapper/InMemorySettlementMapper.java @@ -5,6 +5,7 @@ import java.util.List; import com.ntropy.work.domain.entity.Settlement; +import com.ntropy.work.domain.enums.SettlementMatchStatus; /** * 테스트용 인메모리 SettlementMapper 구현체. @@ -21,13 +22,34 @@ public void insert(Settlement settlement) { } @Override - public boolean existsByJobIdAndPeriod(Long jobId, LocalDate periodStart, LocalDate periodEnd) { + public boolean existsByAccountTransactionId(Long accountTransactionId) { return store.stream().anyMatch(settlement -> - settlement.getJobId().equals(jobId) + accountTransactionId != null && accountTransactionId.equals(settlement.getAccountTransactionId())); + } + + @Override + public boolean existsByUserIdAndStatusAndPeriod(Long userId, SettlementMatchStatus status, + LocalDate periodStart, LocalDate periodEnd) { + return store.stream().anyMatch(settlement -> + settlement.getUserId().equals(userId) + && settlement.getStatus() == status && settlement.getPeriodStart().equals(periodStart) && settlement.getPeriodEnd().equals(periodEnd)); } + @Override + public List findByUserIdAndDepositDateRange(Long userId, LocalDate startDate, LocalDate endDate) { + List result = new ArrayList<>(); + for (Settlement settlement : store) { + if (settlement.getUserId().equals(userId) + && !settlement.getDepositDate().isBefore(startDate) + && !settlement.getDepositDate().isAfter(endDate)) { + result.add(settlement); + } + } + return result; + } + public List findAll() { return store; } diff --git a/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java new file mode 100644 index 0000000..b8092be --- /dev/null +++ b/services/work-service/src/test/java/com/ntropy/work/service/IncomeAnalysisServiceTest.java @@ -0,0 +1,359 @@ +package com.ntropy.work.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.ntropy.common.dto.work.summary.EarnedDepositComparison; +import com.ntropy.common.dto.work.summary.JobFatigueSummary; +import com.ntropy.common.dto.work.summary.JobIncomeSummary; +import com.ntropy.common.dto.work.summary.MonthlyIncomeAnalysisSummary; +import com.ntropy.work.domain.entity.Job; +import com.ntropy.work.domain.entity.Settlement; +import com.ntropy.work.domain.entity.WorkLog; +import com.ntropy.work.domain.enums.SettlementMatchStatus; +import com.ntropy.work.domain.enums.SettlementStatus; +import com.ntropy.work.mapper.InMemoryJobMapper; +import com.ntropy.work.mapper.InMemoryWorkLogMapper; +import com.ntropy.work.mapper.SettlementMapper; + +class IncomeAnalysisServiceTest { + + private static final Long USER_ID = 1L; + private static final Long JOB_A = 100L; + private static final Long JOB_B = 200L; + private static final YearMonth TARGET = YearMonth.of(2026, 7); + private static final LocalDate TARGET_DATE = LocalDate.of(2026, 7, 10); + private static final LocalDate PREVIOUS_MONTH_DATE = LocalDate.of(2026, 6, 10); + + private final InMemoryJobMapper jobMapper = new InMemoryJobMapper(); + private final InMemoryWorkLogMapper workLogMapper = new InMemoryWorkLogMapper(); + private StubSettlementMapper settlementMapper; + private IncomeAnalysisService service; + + @BeforeEach + void setUp() { + settlementMapper = new StubSettlementMapper(); + service = new IncomeAnalysisService(settlementMapper, jobMapper, workLogMapper); + } + + @Test + @DisplayName("MATCHED SETTLEMENT는 총소득과 잡별 소득에 반영된다") + void matchedSettlement_addsToTotalIncomeAndJobIncome() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 50_000L, 1)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(50_000L, result.getTotalIncome()); + assertEquals(0L, result.getUnmatchedIncome()); + assertEquals(1, result.getMatchedTransactionCount()); + assertEquals(1, result.getJobIncomes().size()); + JobIncomeSummary jobIncome = result.getJobIncomes().get(0); + assertEquals(JOB_A, jobIncome.getJobId()); + assertEquals(50_000L, jobIncome.getIncomeAmount()); + assertEquals(1.0, jobIncome.getIncomeRatio()); + assertEquals(JOB_A, result.getPrimaryJobId()); + } + + @Test + @DisplayName("UNMATCHED SETTLEMENT(job_id=null)는 unmatchedIncome에만 합산되고 건수도 반영된다") + void unmatchedSettlement_addsToUnmatchedIncomeOnly() { + settlementMapper.seed(unmatched(TARGET_DATE, 10_000L, 2)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(0L, result.getTotalIncome()); + assertEquals(10_000L, result.getUnmatchedIncome()); + assertEquals(0, result.getMatchedTransactionCount()); + assertEquals(2, result.getUnmatchedTransactionCount()); + assertEquals(0, result.getAmbiguousTransactionCount()); + assertTrue(result.getJobIncomes().isEmpty()); + assertNull(result.getPrimaryJobId()); + } + + @Test + @DisplayName("해당 월 SETTLEMENT가 전혀 없으면 총소득 0원, 잡별 소득은 빈 리스트, 주 소득원은 null이다") + void noIncome_returnsEmptyJobIncomesAndNullPrimaryJob() { + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(0L, result.getTotalIncome()); + assertEquals(0L, result.getUnmatchedIncome()); + assertTrue(result.getJobIncomes().isEmpty()); + assertNull(result.getPrimaryJobId()); + assertNull(result.getPrimaryJobName()); + } + + @Test + @DisplayName("잡별 소득이 동률로 최고액이면 주 소득원을 임의로 정하지 않고 null을 반환한다") + void tiedPrimaryJobIncome_returnsNullPrimaryJob() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + jobMapper.seed(Job.builder().jobId(JOB_B).userId(USER_ID).jobName("잡비").build()); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 10_000L, 1)); + settlementMapper.seed(matched(JOB_B, TARGET_DATE, 10_000L, 1)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(2, result.getJobIncomes().size()); + assertNull(result.getPrimaryJobId()); + } + + @Test + @DisplayName("전월 SETTLEMENT가 실제로 없으면 소득변화액은 이번 달 소득과 같고 변화율은 null이다") + void previousMonthActuallyZero_changeRateNull() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 30_000L, 1)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(0L, result.getPreviousMonthIncome()); + assertEquals(30_000L, result.getIncomeChangeAmount()); + assertNull(result.getIncomeChangeRate()); + } + + @Test + @DisplayName("전월 소득이 있으면 소득변화액/변화율이 정상 계산된다") + void previousMonthNonZero_changeRateCalculated() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, PREVIOUS_MONTH_DATE, 20_000L, 1)); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 30_000L, 1)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(20_000L, result.getPreviousMonthIncome()); + assertEquals(10_000L, result.getIncomeChangeAmount()); + assertEquals(0.5, result.getIncomeChangeRate()); + } + + @Test + @DisplayName("전월 SETTLEMENT 조회가 실패하면 실제 0원과 구분하기 위해 null로 반환한다") + void previousMonthFetchFails_returnsNullNotZero() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 30_000L, 1)); + settlementMapper.failFor(YearMonth.of(2026, 6)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(30_000L, result.getTotalIncome()); + assertNull(result.getPreviousMonthIncome()); + assertNull(result.getIncomeChangeAmount()); + assertNull(result.getIncomeChangeRate()); + } + + @Test + @DisplayName("이번 달 SETTLEMENT 조회 자체가 실패하면 빈 정상 응답 대신 예외를 전파한다") + void currentMonthFetchFails_propagatesException() { + settlementMapper.failFor(TARGET); + + assertThrows(RuntimeException.class, () -> service.getMonthlyIncomeAnalysis(USER_ID, TARGET)); + } + + @Test + @DisplayName("유효한 월이 2개 미만이면 변동성은 null이다") + void volatility_lessThanTwoValidMonths_returnsNull() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 30_000L, 1)); + settlementMapper.failFor(YearMonth.of(2026, 6)); + settlementMapper.failFor(YearMonth.of(2026, 5)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertNull(result.getIncomeVolatility()); + } + + @Test + @DisplayName("최근 3개월 평균소득이 0이면 변동성은 null이다") + void volatility_meanIncomeZero_returnsNull() { + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertNull(result.getIncomeVolatility()); + } + + @Test + @DisplayName("최근 3개월 소득이 모두 확인되면 변동계수가 계산된다") + void volatility_threeValidMonths_calculatesCoefficient() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, LocalDate.of(2026, 5, 10), 10_000L, 1)); + settlementMapper.seed(matched(JOB_A, PREVIOUS_MONTH_DATE, 20_000L, 1)); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 30_000L, 1)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + // mean=20000, population stddev = sqrt(((10000-20000)^2+0+(10000)^2)/3) ≈ 8164.97 + assertEquals(8164.97 / 20000.0, result.getIncomeVolatility(), 0.001); + } + + @Test + @DisplayName("발생소득(WorkLog 기준)과 실입금소득(SETTLEMENT 기준)을 잡별로 비교한다") + void earnedDepositComparison_comparesWorkLogEarnedAndSettlementDeposited() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + settlementMapper.seed(matched(JOB_A, TARGET_DATE, 40_000L, 1)); + workLogMapper.insert(workLog(JOB_A, TARGET_DATE, "CONFIRMED", 50_000L, 3L, SettlementStatus.COMPLETED)); + workLogMapper.insert(workLog(JOB_A, TARGET_DATE.plusDays(1), "PLANNED", 99_999L, 3L, SettlementStatus.NONE)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(1, result.getEarnedDepositComparisons().size()); + EarnedDepositComparison comparison = result.getEarnedDepositComparisons().get(0); + assertEquals(JOB_A, comparison.getJobId()); + assertEquals(50_000L, comparison.getEarnedIncome()); + assertEquals(40_000L, comparison.getDepositedIncome()); + assertEquals(-10_000L, comparison.getDifferenceAmount()); + } + + @Test + @DisplayName("확정됐지만 아직 SETTLEMENT로 완료 처리되지 않은 근무일지의 예상소득은 pendingSettlementIncome에 합산된다") + void pendingSettlementIncome_sumsConfirmedButNotYetCompletedWorkLogs() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + workLogMapper.insert(workLog(JOB_A, TARGET_DATE, "CONFIRMED", 30_000L, 3L, SettlementStatus.PENDING)); + workLogMapper.insert(workLog(JOB_A, TARGET_DATE.plusDays(1), "CONFIRMED", 20_000L, 3L, SettlementStatus.COMPLETED)); + workLogMapper.insert(workLog(JOB_A, TARGET_DATE.plusDays(2), "PLANNED", 99_999L, 3L, SettlementStatus.NONE)); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(30_000L, result.getPendingSettlementIncome()); + } + + @Test + @DisplayName("잡별 피로도는 근무시간 가중평균이며 근무시간이 없으면 null이다") + void fatigueSummary_weightedAverageByWorkMinutes() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("잡에이").build()); + workLogMapper.insert(workLogWithTime(JOB_A, TARGET_DATE, "CONFIRMED", 10_000L, 2L, + LocalTime.of(9, 0), LocalTime.of(11, 0))); + workLogMapper.insert(workLogWithTime(JOB_A, TARGET_DATE.plusDays(1), "PLANNED", 10_000L, 4L, + LocalTime.of(9, 0), LocalTime.of(10, 0))); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertEquals(1, result.getFatigueSummaries().size()); + JobFatigueSummary fatigue = result.getFatigueSummaries().get(0); + assertEquals(2, fatigue.getWorkDays()); + assertEquals(180L, fatigue.getTotalWorkMinutes()); + // (2*120 + 4*60) / 180 = 480/180 ≈ 2.666... + assertEquals(480.0 / 180.0, fatigue.getAverageFatigue(), 0.001); + assertEquals(4L, fatigue.getLatestFatigue()); + } + + @Test + @DisplayName("활동(매칭소득/근무일지)이 없는 잡은 발생소득 비교와 피로도 목록에 포함하지 않는다") + void jobsWithoutActivity_areExcludedFromComparisonsAndFatigue() { + jobMapper.seed(Job.builder().jobId(JOB_A).userId(USER_ID).jobName("활동없는잡").build()); + + MonthlyIncomeAnalysisSummary result = service.getMonthlyIncomeAnalysis(USER_ID, TARGET); + + assertTrue(result.getEarnedDepositComparisons().isEmpty()); + assertTrue(result.getFatigueSummaries().isEmpty()); + } + + private static Settlement matched(Long jobId, LocalDate depositDate, long amount, int count) { + return Settlement.builder() + .userId(USER_ID) + .status(SettlementMatchStatus.MATCHED) + .jobId(jobId) + .periodStart(depositDate) + .periodEnd(depositDate) + .depositDate(depositDate) + .expectedAmount(0L) + .actualAmount(amount) + .transactionCount(count) + .accountTransactionId(1L) + .matchedAt(LocalDateTime.now()) + .build(); + } + + private static Settlement unmatched(LocalDate depositDate, long amount, int count) { + return Settlement.builder() + .userId(USER_ID) + .status(SettlementMatchStatus.UNMATCHED) + .jobId(null) + .periodStart(depositDate) + .periodEnd(depositDate) + .depositDate(depositDate) + .expectedAmount(0L) + .actualAmount(amount) + .transactionCount(count) + .accountTransactionId(null) + .matchedAt(LocalDateTime.now()) + .build(); + } + + private static WorkLog workLog(Long jobId, LocalDate workDate, String status, long estimatedIncome, long fatigue, + SettlementStatus settlementStatus) { + return workLogWithTime(jobId, workDate, status, estimatedIncome, fatigue, + LocalTime.of(9, 0), LocalTime.of(10, 0), settlementStatus); + } + + private static WorkLog workLogWithTime(Long jobId, LocalDate workDate, String status, long estimatedIncome, + long fatigue, LocalTime startTime, LocalTime endTime) { + return workLogWithTime(jobId, workDate, status, estimatedIncome, fatigue, startTime, endTime, + SettlementStatus.NONE); + } + + private static WorkLog workLogWithTime(Long jobId, LocalDate workDate, String status, long estimatedIncome, + long fatigue, LocalTime startTime, LocalTime endTime, + SettlementStatus settlementStatus) { + return WorkLog.builder() + .userId(USER_ID) + .jobId(jobId) + .workDate(workDate) + .startTime(startTime) + .endTime(endTime) + .estimatedIncome(estimatedIncome) + .fatigue(fatigue) + .status(status) + .settlementStatus(settlementStatus) + .build(); + } + + private static final class StubSettlementMapper implements SettlementMapper { + private final List all = new ArrayList<>(); + private final List failingMonths = new ArrayList<>(); + + void seed(Settlement settlement) { + all.add(settlement); + } + + void failFor(YearMonth yearMonth) { + failingMonths.add(yearMonth); + } + + @Override + public void insert(Settlement settlement) { + throw new UnsupportedOperationException("이 테스트 더블은 조회만 지원합니다."); + } + + @Override + public boolean existsByAccountTransactionId(Long accountTransactionId) { + throw new UnsupportedOperationException("이 테스트 더블은 조회만 지원합니다."); + } + + @Override + public boolean existsByUserIdAndStatusAndPeriod(Long userId, SettlementMatchStatus status, + LocalDate periodStart, LocalDate periodEnd) { + throw new UnsupportedOperationException("이 테스트 더블은 조회만 지원합니다."); + } + + @Override + public List findByUserIdAndDepositDateRange(Long userId, LocalDate startDate, LocalDate endDate) { + if (failingMonths.contains(YearMonth.from(startDate))) { + throw new RuntimeException("조회 실패 (테스트용)"); + } + return all.stream() + .filter(s -> s.getUserId().equals(userId)) + .filter(s -> !s.getDepositDate().isBefore(startDate) && !s.getDepositDate().isAfter(endDate)) + .toList(); + } + } +} diff --git a/services/work-service/src/test/java/com/ntropy/work/service/JobPlatformMappingServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/JobPlatformMappingServiceTest.java index 825a0fe..dfc8d9b 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/JobPlatformMappingServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/JobPlatformMappingServiceTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Platform; import com.ntropy.work.mapper.InMemoryJobPlatformMappingMapper; import com.ntropy.work.mapper.InMemoryPlatformMapper; @@ -41,7 +42,7 @@ void register_success() { @Test @DisplayName("존재하지 않는 플랫폼으로 등록하면 실패한다") void register_unknownPlatform_throws() { - assertThrows(IllegalArgumentException.class, () -> service.register(JOB_ID, 999L)); + assertThrows(ServiceException.class, () -> service.register(JOB_ID, 999L)); } @Test @@ -49,7 +50,7 @@ void register_unknownPlatform_throws() { void register_duplicateMapping_throws() { service.register(JOB_ID, PLATFORM_ID); - assertThrows(IllegalArgumentException.class, () -> service.register(JOB_ID, PLATFORM_ID)); + assertThrows(ServiceException.class, () -> service.register(JOB_ID, PLATFORM_ID)); } @Test diff --git a/services/work-service/src/test/java/com/ntropy/work/service/JobServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/JobServiceTest.java index 356ebad..75dbfe3 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/JobServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/JobServiceTest.java @@ -6,13 +6,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalTime; -import java.util.Collections; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Category; import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.JobSchedule; @@ -67,7 +67,7 @@ void registerJob_withoutSchedule_succeeds() { void registerJob_hourlyMissingWage_throws() { Job job = baseJob().hourlyWage(null).build(); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(job, null)); + assertThrows(ServiceException.class, () -> jobService.registerJob(job, null)); } @Test @@ -79,7 +79,7 @@ void registerJob_perTaskMissingTaskPerHour_throws() { .perTaskWage(3000) .build(); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(job, null)); + assertThrows(ServiceException.class, () -> jobService.registerJob(job, null)); } @Test @@ -87,7 +87,7 @@ void registerJob_perTaskMissingTaskPerHour_throws() { void registerJob_monthlyMissingWage_throws() { Job job = baseJob().settlementType(SettlementType.MONTHLY).hourlyWage(null).build(); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(job, null)); + assertThrows(ServiceException.class, () -> jobService.registerJob(job, null)); } @Test @@ -95,7 +95,7 @@ void registerJob_monthlyMissingWage_throws() { void registerJob_regularWithoutSchedule_throws() { Job job = baseJob().isRegular(true).build(); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(job, null)); + assertThrows(ServiceException.class, () -> jobService.registerJob(job, null)); } @Test @@ -104,7 +104,7 @@ void registerJob_nonRegularWithSchedule_throws() { Job job = baseJob().isRegular(false).build(); List schedules = List.of(scheduleOf("MON", LocalTime.of(18, 0), LocalTime.of(22, 0))); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(job, schedules)); + assertThrows(ServiceException.class, () -> jobService.registerJob(job, schedules)); } @Test @@ -127,7 +127,7 @@ void registerJob_newSchedulesOverlapWithEachOther_throws() { scheduleOf("MON", LocalTime.of(21, 0), LocalTime.of(23, 0)) ); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(job, schedules)); + assertThrows(ServiceException.class, () -> jobService.registerJob(job, schedules)); } @Test @@ -139,7 +139,7 @@ void registerJob_overlapsWithAnotherJobOfSameUser_throws() { Job secondJob = baseJob().jobName("쿠팡이츠 배달").isRegular(true).build(); List overlapping = List.of(scheduleOf("MON", LocalTime.of(21, 0), LocalTime.of(23, 0))); - assertThrows(IllegalArgumentException.class, () -> jobService.registerJob(secondJob, overlapping)); + assertThrows(ServiceException.class, () -> jobService.registerJob(secondJob, overlapping)); } @Test @@ -179,7 +179,7 @@ void updateJob_reappliesSettlementValidation() { Job job = jobService.registerJob(baseJob().build(), null); Job patch = baseJob().jobId(job.getJobId()).hourlyWage(null).build(); - assertThrows(IllegalArgumentException.class, () -> jobService.updateJob(patch, null)); + assertThrows(ServiceException.class, () -> jobService.updateJob(USER_ID, patch, null)); } @Test @@ -187,7 +187,16 @@ void updateJob_reappliesSettlementValidation() { void updateJob_notFound_throws() { Job patch = baseJob().jobId(999L).build(); - assertThrows(IllegalArgumentException.class, () -> jobService.updateJob(patch, null)); + assertThrows(ServiceException.class, () -> jobService.updateJob(USER_ID, patch, null)); + } + + @Test + @DisplayName("다른 유저의 잡을 수정하면 실패한다") + void updateJob_notOwner_throws() { + Job job = jobService.registerJob(baseJob().build(), null); + Job patch = baseJob().jobId(job.getJobId()).build(); + + assertThrows(ServiceException.class, () -> jobService.updateJob(999L, patch, null)); } @Test @@ -195,15 +204,23 @@ void updateJob_notFound_throws() { void deactivateJob_setsIsActiveFalse() { Job job = jobService.registerJob(baseJob().build(), null); - jobService.deactivateJob(job.getJobId()); + jobService.deactivateJob(USER_ID, job.getJobId()); assertEquals(false, jobService.findById(job.getJobId()).getIsActive()); } + @Test + @DisplayName("다른 유저의 잡을 비활성화하면 실패한다") + void deactivateJob_notOwner_throws() { + Job job = jobService.registerJob(baseJob().build(), null); + + assertThrows(ServiceException.class, () -> jobService.deactivateJob(999L, job.getJobId())); + } + @Test @DisplayName("존재하지 않는 잡을 조회하면 실패한다") void findById_notFound_throws() { - assertThrows(IllegalArgumentException.class, () -> jobService.findById(999L)); + assertThrows(ServiceException.class, () -> jobService.findById(999L)); } @Test @@ -266,7 +283,7 @@ void updateJob_recalculatesMonthlyExpectedIncome() { List.of(scheduleOf("MON", LocalTime.of(18, 0), LocalTime.of(23, 0)))); Job patch = baseJob().jobId(job.getJobId()).isRegular(true).hourlyWage(15000).build(); - Job result = jobService.updateJob(patch, + Job result = jobService.updateJob(USER_ID, patch, List.of(scheduleOf("MON", LocalTime.of(18, 0), LocalTime.of(23, 0)))); assertEquals(Math.round(5 * (30.0 / 7.0) * 15000), result.getMonthlyExpectedIncome()); @@ -279,7 +296,7 @@ void updateJob_regularWithoutSchedule_throws() { List.of(scheduleOf("MON", LocalTime.of(18, 0), LocalTime.of(22, 0)))); Job patch = baseJob().jobId(job.getJobId()).isRegular(true).build(); - assertThrows(IllegalArgumentException.class, () -> jobService.updateJob(patch, null)); + assertThrows(ServiceException.class, () -> jobService.updateJob(USER_ID, patch, null)); } @Test @@ -290,7 +307,7 @@ void updateJob_replacesSchedulesEntirely() { Job patch = baseJob().jobId(job.getJobId()).isRegular(true).build(); List newSchedules = List.of(scheduleOf("TUE", LocalTime.of(9, 0), LocalTime.of(13, 0))); - jobService.updateJob(patch, newSchedules); + jobService.updateJob(USER_ID, patch, newSchedules); List saved = jobScheduleMapper.findByJobId(job.getJobId()); assertEquals(1, saved.size()); @@ -306,7 +323,7 @@ void updateJob_sameScheduleAsBefore_doesNotThrowOverlap() { Job patch = baseJob().jobId(job.getJobId()).isRegular(true).build(); List sameSchedule = List.of(scheduleOf("MON", LocalTime.of(18, 0), LocalTime.of(22, 0))); - Job result = jobService.updateJob(patch, sameSchedule); + Job result = jobService.updateJob(USER_ID, patch, sameSchedule); assertEquals(1, jobScheduleMapper.findByJobId(result.getJobId()).size()); } @@ -322,6 +339,6 @@ void updateJob_overlapsWithAnotherJob_throws() { Job patch = baseJob().jobId(secondJob.getJobId()).jobName("쿠팡이츠 배달").isRegular(true).build(); List overlapping = List.of(scheduleOf("MON", LocalTime.of(19, 0), LocalTime.of(21, 0))); - assertThrows(IllegalArgumentException.class, () -> jobService.updateJob(patch, overlapping)); + assertThrows(ServiceException.class, () -> jobService.updateJob(USER_ID, patch, overlapping)); } } diff --git a/services/work-service/src/test/java/com/ntropy/work/service/SavingGoalServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/SavingGoalServiceTest.java index 991a1fb..7574217 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/SavingGoalServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/SavingGoalServiceTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.SavingGoal; import com.ntropy.work.mapper.InMemorySavingGoalMapper; @@ -46,7 +47,7 @@ void register_success() { void register_invalidTargetAmount_throws() { SavingGoal invalid = validGoal().targetAmount(0L).build(); - assertThrows(IllegalArgumentException.class, () -> service.registerSavingGoal(invalid)); + assertThrows(ServiceException.class, () -> service.registerSavingGoal(invalid)); } @Test @@ -54,7 +55,7 @@ void register_invalidTargetAmount_throws() { void register_invalidLaborIntensity_throws() { SavingGoal invalid = validGoal().laborIntensity(6L).build(); - assertThrows(IllegalArgumentException.class, () -> service.registerSavingGoal(invalid)); + assertThrows(ServiceException.class, () -> service.registerSavingGoal(invalid)); } @Test @@ -63,7 +64,7 @@ void register_duplicateMonth_throws() { service.registerSavingGoal(validGoal().build()); SavingGoal duplicate = validGoal().targetAmount(3_000_000L).laborIntensity(2L).build(); - assertThrows(IllegalArgumentException.class, () -> service.registerSavingGoal(duplicate)); + assertThrows(ServiceException.class, () -> service.registerSavingGoal(duplicate)); } @Test diff --git a/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java index d065d1a..a87fd9e 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java @@ -1,6 +1,7 @@ package com.ntropy.work.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; @@ -19,6 +20,7 @@ import com.ntropy.work.domain.entity.Platform; import com.ntropy.work.domain.entity.Settlement; import com.ntropy.work.domain.entity.WorkLog; +import com.ntropy.work.domain.enums.SettlementMatchStatus; import com.ntropy.work.domain.enums.SettlementStatus; import com.ntropy.work.mapper.InMemoryJobMapper; import com.ntropy.work.mapper.InMemoryJobPlatformMappingMapper; @@ -58,7 +60,7 @@ void setUp() { } @Test - @DisplayName("입금 거래가 매칭되면 SETTLEMENT가 생성되고 기간 내 CONFIRMED 로그만 COMPLETED로 갱신된다") + @DisplayName("입금 거래가 매칭되면 MATCHED SETTLEMENT가 생성되고 기간 내 CONFIRMED 로그만 COMPLETED로 갱신된다") void processSettlement_matched_createsSettlementAndUpdatesConfirmedLogsOnly() { jobPlatformMappingMapper.insert(JobPlatformMapping.builder().jobId(JOB_ID).platformId(PLATFORM_ID).build()); WorkLog inPeriodConfirmed = workLog(PERIOD_DATE, "CONFIRMED", 50_000L); @@ -74,11 +76,15 @@ void processSettlement_matched_createsSettlementAndUpdatesConfirmedLogsOnly() { List settlements = settlementMapper.findAll(); assertEquals(1, settlements.size()); Settlement settlement = settlements.get(0); + assertEquals(SettlementMatchStatus.MATCHED, settlement.getStatus()); + assertEquals(USER_ID, settlement.getUserId()); assertEquals(JOB_ID, settlement.getJobId()); assertEquals(PERIOD_DATE, settlement.getPeriodStart()); assertEquals(PERIOD_DATE, settlement.getPeriodEnd()); + assertEquals(PROCESS_DATE, settlement.getDepositDate()); assertEquals(50_000L, settlement.getExpectedAmount()); assertEquals(48_000L, settlement.getActualAmount()); + assertEquals(1, settlement.getTransactionCount()); assertEquals(999L, settlement.getAccountTransactionId()); assertEquals(SettlementStatus.COMPLETED, workLogMapper.findById(inPeriodConfirmed.getLogId()).getSettlementStatus()); @@ -87,34 +93,62 @@ void processSettlement_matched_createsSettlementAndUpdatesConfirmedLogsOnly() { } @Test - @DisplayName("PLATFORM과 매칭되지 않는 거래는 SETTLEMENT를 생성하지 않는다") - void processSettlement_unmatchedTransaction_createsNothing() { + @DisplayName("PLATFORM과 매칭되지 않는 거래는 job_id=null인 UNMATCHED SETTLEMENT로 합산 저장된다") + void processSettlement_unmatchedTransaction_savesUnmatchedSettlement() { incomingTransactionQueryClient.transactions = List.of( new NormalizedIncomingTransaction(1L, PROCESS_DATE, LocalTime.NOON, "알수없는입금처", BigDecimal.valueOf(10_000L)) ); service.processSettlement(USER_ID, PROCESS_DATE); - assertTrue(settlementMapper.findAll().isEmpty()); + List settlements = settlementMapper.findAll(); + assertEquals(1, settlements.size()); + Settlement settlement = settlements.get(0); + assertEquals(SettlementMatchStatus.UNMATCHED, settlement.getStatus()); + assertNull(settlement.getJobId()); + assertEquals(USER_ID, settlement.getUserId()); + assertEquals(10_000L, settlement.getActualAmount()); + assertEquals(1, settlement.getTransactionCount()); + assertEquals(PROCESS_DATE, settlement.getDepositDate()); } @Test - @DisplayName("매칭되는 PLATFORM이지만 사용자가 JOB으로 등록하지 않았으면 SETTLEMENT를 생성하지 않는다") - void processSettlement_platformNotRegisteredAsJob_createsNothing() { + @DisplayName("같은 날짜의 UNMATCHED 거래 여러 건은 하나의 SETTLEMENT 행으로 합산된다") + void processSettlement_multipleUnmatchedTransactionsSameDay_aggregatesIntoOneRow() { + incomingTransactionQueryClient.transactions = List.of( + new NormalizedIncomingTransaction(1L, PROCESS_DATE, LocalTime.NOON, "알수없는입금처1", BigDecimal.valueOf(10_000L)), + new NormalizedIncomingTransaction(2L, PROCESS_DATE, LocalTime.NOON, "알수없는입금처2", BigDecimal.valueOf(5_000L)) + ); + + service.processSettlement(USER_ID, PROCESS_DATE); + + List settlements = settlementMapper.findAll(); + assertEquals(1, settlements.size()); + assertEquals(15_000L, settlements.get(0).getActualAmount()); + assertEquals(2, settlements.get(0).getTransactionCount()); + } + + @Test + @DisplayName("매칭되는 PLATFORM이지만 사용자가 JOB으로 등록하지 않았으면 UNMATCHED로 저장된다") + void processSettlement_platformNotRegisteredAsJob_savesUnmatchedSettlement() { incomingTransactionQueryClient.transactions = List.of(transaction(1L, 10_000L)); service.processSettlement(USER_ID, PROCESS_DATE); - assertTrue(settlementMapper.findAll().isEmpty()); + List settlements = settlementMapper.findAll(); + assertEquals(1, settlements.size()); + assertEquals(SettlementMatchStatus.UNMATCHED, settlements.get(0).getStatus()); } @Test - @DisplayName("이미 같은 job+기간의 SETTLEMENT가 있으면 중복 생성하지 않는다") - void processSettlement_alreadySettled_skipsDuplicate() { + @DisplayName("이미 같은 accountTransactionId로 처리된 거래는 재처리하지 않는다 (배치 재실행 방지)") + void processSettlement_sameTransactionAlreadySettled_skipsDuplicate() { jobPlatformMappingMapper.insert(JobPlatformMapping.builder().jobId(JOB_ID).platformId(PLATFORM_ID).build()); settlementMapper.insert(Settlement.builder() + .userId(USER_ID).status(SettlementMatchStatus.MATCHED) .jobId(JOB_ID).periodStart(PERIOD_DATE).periodEnd(PERIOD_DATE) - .expectedAmount(0L).actualAmount(0L).accountTransactionId(1L) + .depositDate(PROCESS_DATE) + .expectedAmount(0L).actualAmount(48_000L).transactionCount(1).accountTransactionId(999L) .matchedAt(java.time.LocalDateTime.now()) .build()); incomingTransactionQueryClient.transactions = List.of(transaction(999L, 48_000L)); @@ -124,6 +158,42 @@ void processSettlement_alreadySettled_skipsDuplicate() { assertEquals(1, settlementMapper.findAll().size()); } + @Test + @DisplayName("같은 잡·같은 정산기간에 서로 다른 거래 2건이 들어오면 각각 SETTLEMENT로 반영된다") + void processSettlement_twoDistinctTransactionsSamePeriod_bothCreateSettlements() { + jobPlatformMappingMapper.insert(JobPlatformMapping.builder().jobId(JOB_ID).platformId(PLATFORM_ID).build()); + incomingTransactionQueryClient.transactions = List.of( + transaction(111L, 30_000L), + transaction(222L, 20_000L) + ); + + service.processSettlement(USER_ID, PROCESS_DATE); + + List settlements = settlementMapper.findAll(); + assertEquals(2, settlements.size()); + long totalActualAmount = settlements.stream().mapToLong(Settlement::getActualAmount).sum(); + assertEquals(50_000L, totalActualAmount); + } + + @Test + @DisplayName("이미 같은 날짜의 UNMATCHED SETTLEMENT가 있으면 중복 생성하지 않는다") + void processSettlement_alreadyUnmatchedSettled_skipsDuplicate() { + settlementMapper.insert(Settlement.builder() + .userId(USER_ID).status(SettlementMatchStatus.UNMATCHED) + .jobId(null).periodStart(PROCESS_DATE).periodEnd(PROCESS_DATE) + .depositDate(PROCESS_DATE) + .expectedAmount(0L).actualAmount(5_000L).transactionCount(1).accountTransactionId(null) + .matchedAt(java.time.LocalDateTime.now()) + .build()); + incomingTransactionQueryClient.transactions = List.of( + new NormalizedIncomingTransaction(1L, PROCESS_DATE, LocalTime.NOON, "알수없는입금처", BigDecimal.valueOf(10_000L)) + ); + + service.processSettlement(USER_ID, PROCESS_DATE); + + assertEquals(1, settlementMapper.findAll().size()); + } + private static WorkLog workLog(LocalDate workDate, String status, long estimatedIncome) { return WorkLog.builder() .userId(USER_ID) diff --git a/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java index 68209f9..674af1b 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/WorkLogServiceTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.ntropy.common.exception.ServiceException; import com.ntropy.work.domain.entity.Job; import com.ntropy.work.domain.entity.WorkLog; import com.ntropy.work.domain.enums.SettlementStatus; @@ -117,7 +118,7 @@ void registerPlan_keepsProvidedFatigue() { void registerPlan_missingRequiredField_throws() { WorkLog plan = planOf(null, LocalTime.of(18, 0), LocalTime.of(22, 0)); - assertThrows(IllegalArgumentException.class, () -> workLogService.registerPlan(plan)); + assertThrows(ServiceException.class, () -> workLogService.registerPlan(plan)); } @Test @@ -128,7 +129,7 @@ void registerPlan_overlappingTime_throws() { WorkLog overlapping = planOf(job.getJobId(), LocalTime.of(20, 0), LocalTime.of(23, 0)); - assertThrows(IllegalArgumentException.class, () -> workLogService.registerPlan(overlapping)); + assertThrows(ServiceException.class, () -> workLogService.registerPlan(overlapping)); } @Test @@ -140,7 +141,7 @@ void registerPlan_overlapCheckIsAcrossJobsForSameUser() { WorkLog overlapping = planOf(jobB.getJobId(), LocalTime.of(19, 0), LocalTime.of(21, 0)); - assertThrows(IllegalArgumentException.class, () -> workLogService.registerPlan(overlapping)); + assertThrows(ServiceException.class, () -> workLogService.registerPlan(overlapping)); } @Test @@ -149,7 +150,7 @@ void registerActual_requiresFatigue() { Job job = hourlyJob(); WorkLog actual = planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0)); - assertThrows(IllegalArgumentException.class, () -> workLogService.registerActual(actual)); + assertThrows(ServiceException.class, () -> workLogService.registerActual(actual)); } @Test @@ -159,7 +160,7 @@ void registerActual_perTaskWithoutTaskCount_throws() { WorkLog actual = planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0)); actual.setFatigue(4L); - assertThrows(IllegalArgumentException.class, () -> workLogService.registerActual(actual)); + assertThrows(ServiceException.class, () -> workLogService.registerActual(actual)); } @Test @@ -196,7 +197,7 @@ void editWorkLog_onlyPatchedFieldsChange() { WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); WorkLog patch = WorkLog.builder().endTime(LocalTime.of(23, 0)).build(); - WorkLog result = workLogService.editWorkLog(plan.getLogId(), patch); + WorkLog result = workLogService.editWorkLog(USER_ID, plan.getLogId(), patch); assertEquals(LocalTime.of(18, 0), result.getStartTime()); assertEquals(LocalTime.of(23, 0), result.getEndTime()); @@ -212,7 +213,7 @@ void editWorkLog_overlapCheckExcludesSelf() { WorkLog patch = WorkLog.builder().startTime(LocalTime.of(18, 30)).build(); assertEquals(LocalTime.of(18, 30), - workLogService.editWorkLog(plan.getLogId(), patch).getStartTime()); + workLogService.editWorkLog(USER_ID, plan.getLogId(), patch).getStartTime()); } @Test @@ -220,7 +221,17 @@ void editWorkLog_overlapCheckExcludesSelf() { void editWorkLog_notFound_throws() { WorkLog patch = WorkLog.builder().endTime(LocalTime.of(23, 0)).build(); - assertThrows(IllegalArgumentException.class, () -> workLogService.editWorkLog(999L, patch)); + assertThrows(ServiceException.class, () -> workLogService.editWorkLog(USER_ID, 999L, patch)); + } + + @Test + @DisplayName("다른 유저의 근무일지를 수정하면 실패한다") + void editWorkLog_notOwner_throws() { + Job job = hourlyJob(); + WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); + WorkLog patch = WorkLog.builder().endTime(LocalTime.of(23, 0)).build(); + + assertThrows(ServiceException.class, () -> workLogService.editWorkLog(999L, plan.getLogId(), patch)); } @Test @@ -229,7 +240,7 @@ void confirmWorkLog_transitionsPlannedToConfirmed() { Job job = hourlyJob(); WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); - WorkLog result = workLogService.confirmWorkLog(plan.getLogId(), WorkLog.builder().build()); + WorkLog result = workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build()); assertEquals("CONFIRMED", result.getStatus()); assertEquals(SettlementStatus.PENDING, result.getSettlementStatus()); @@ -240,10 +251,10 @@ void confirmWorkLog_transitionsPlannedToConfirmed() { void confirmWorkLog_alreadyConfirmed_throws() { Job job = hourlyJob(); WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); - workLogService.confirmWorkLog(plan.getLogId(), WorkLog.builder().build()); + workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build()); - assertThrows(IllegalStateException.class, - () -> workLogService.confirmWorkLog(plan.getLogId(), WorkLog.builder().build())); + assertThrows(ServiceException.class, + () -> workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build())); } @Test @@ -252,8 +263,18 @@ void confirmWorkLog_perTaskWithoutTaskCount_throws() { Job job = perTaskJob(); WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); - assertThrows(IllegalArgumentException.class, - () -> workLogService.confirmWorkLog(plan.getLogId(), WorkLog.builder().build())); + assertThrows(ServiceException.class, + () -> workLogService.confirmWorkLog(USER_ID, plan.getLogId(), WorkLog.builder().build())); + } + + @Test + @DisplayName("다른 유저의 근무일지를 확정하면 실패한다") + void confirmWorkLog_notOwner_throws() { + Job job = hourlyJob(); + WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); + + assertThrows(ServiceException.class, + () -> workLogService.confirmWorkLog(999L, plan.getLogId(), WorkLog.builder().build())); } @Test @@ -262,14 +283,23 @@ void deleteWorkLog_removesRecord() { Job job = hourlyJob(); WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); - workLogService.deleteWorkLog(plan.getLogId()); + workLogService.deleteWorkLog(USER_ID, plan.getLogId()); - assertThrows(IllegalArgumentException.class, () -> workLogService.findById(plan.getLogId())); + assertThrows(ServiceException.class, () -> workLogService.findById(plan.getLogId())); } @Test @DisplayName("존재하지 않는 근무일지를 삭제하면 실패한다") void deleteWorkLog_notFound_throws() { - assertThrows(IllegalArgumentException.class, () -> workLogService.deleteWorkLog(999L)); + assertThrows(ServiceException.class, () -> workLogService.deleteWorkLog(USER_ID, 999L)); + } + + @Test + @DisplayName("다른 유저의 근무일지를 삭제하면 실패한다") + void deleteWorkLog_notOwner_throws() { + Job job = hourlyJob(); + WorkLog plan = workLogService.registerPlan(planOf(job.getJobId(), LocalTime.of(18, 0), LocalTime.of(22, 0))); + + assertThrows(ServiceException.class, () -> workLogService.deleteWorkLog(999L, plan.getLogId())); } }