diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceEligibility.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceEligibility.java
new file mode 100644
index 00000000..fa60974a
--- /dev/null
+++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceEligibility.java
@@ -0,0 +1,35 @@
+package com.concrete.buildup.domain.payroll.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 4대보험 가입 여부 DTO
+ *
+ *
근로자의 4대보험 가입 여부 정보를 담는 DTO입니다.
+ * 산재보험은 근로자 부담이 없으므로 포함하지 않습니다.
+ *
+ * @author Build-Up Team
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "4대보험 가입 여부")
+public class InsuranceEligibility {
+
+ @Schema(description = "고용보험 가입 여부", example = "true", required = true)
+ private Boolean employmentInsurance;
+
+ @Schema(description = "건강보험 가입 여부", example = "true", required = true)
+ private Boolean healthInsurance;
+
+ @Schema(description = "국민연금 가입 여부", example = "false", required = true)
+ private Boolean nationalPension;
+
+ // 산재보험은 근로자 부담이 없으므로 제외
+}
\ No newline at end of file
diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceRates.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceRates.java
new file mode 100644
index 00000000..20b815f6
--- /dev/null
+++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/InsuranceRates.java
@@ -0,0 +1,54 @@
+package com.concrete.buildup.domain.payroll.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * 4대보험 요율 DTO
+ *
+ * 4대보험의 근로자 부담 요율 정보를 담는 DTO입니다.
+ * 2025년 기준 요율: 국민연금 4.5%, 건강보험 3.545%, 장기요양 12.81%, 고용보험 0.9%
+ *
+ * @author Build-Up Team
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "4대보험 요율 정보")
+public class InsuranceRates {
+
+ @Schema(description = "국민연금 요율 (2025년 기준: 0.045)", example = "0.045", required = true)
+ private BigDecimal nationalPension;
+
+ @Schema(description = "건강보험 요율 (2025년 기준: 0.03545)", example = "0.03545", required = true)
+ private BigDecimal healthInsurance;
+
+ @Schema(description = "장기요양보험 요율 (건강보험료의 비율, 2025년 기준: 0.1281)", example = "0.1281", required = true)
+ private BigDecimal longTermCare;
+
+ @Schema(description = "고용보험 요율 (2025년 기준: 0.009)", example = "0.009", required = true)
+ private BigDecimal employmentInsurance;
+
+ // 산재보험은 사업주 전액 부담이므로 제외
+
+ /**
+ * 2025년 기준 기본 요율로 초기화된 InsuranceRates 생성
+ *
+ * @return 2025년 기준 요율
+ */
+ public static InsuranceRates defaultRates2025() {
+ return InsuranceRates.builder()
+ .nationalPension(new BigDecimal("0.045"))
+ .healthInsurance(new BigDecimal("0.03545"))
+ .longTermCare(new BigDecimal("0.1281"))
+ .employmentInsurance(new BigDecimal("0.009"))
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/OvertimeConditions.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/OvertimeConditions.java
new file mode 100644
index 00000000..189a7414
--- /dev/null
+++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/OvertimeConditions.java
@@ -0,0 +1,56 @@
+package com.concrete.buildup.domain.payroll.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 가산수당 발생/지급 조건 DTO
+ *
+ * 연장/야간/휴일 가산수당의 지급 조건을 판단하기 위한 정보를 담는 DTO입니다.
+ * 5인 이상 사업장은 가산수당 지급 의무, 5인 미만은 선택적 지급 가능합니다.
+ *
+ * @author Build-Up Team
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "가산수당 발생/지급 조건")
+public class OvertimeConditions {
+
+ // ========== 사업장 규모 ==========
+
+ @Schema(description = "상시근로자 수 (5인 이상이면 가산수당 의무 지급)", example = "10", required = true)
+ private Integer totalEmployees;
+
+ // ========== 5인 미만 사업장의 자율 지급 정책 ==========
+
+ @Schema(description = "연장수당 자율 지급 여부 (5인 미만일 때)", example = "true")
+ private Boolean autoPayOvertime;
+
+ @Schema(description = "야간수당 자율 지급 여부 (5인 미만일 때)", example = "true")
+ private Boolean autoPayNight;
+
+ @Schema(description = "휴일수당 자율 지급 여부 (5인 미만일 때)", example = "true")
+ private Boolean autoPayHoliday;
+
+ // ========== 연장근로 발생 조건 ==========
+
+ @Schema(description = "일 8시간 초과 여부", example = "true")
+ private Boolean exceedsDailyLimit;
+
+ @Schema(description = "주 40시간 초과 여부", example = "false")
+ private Boolean exceedsWeeklyLimit;
+
+ // ========== 야간/휴일 근로 존재 여부 ==========
+
+ @Schema(description = "야간근로 존재 여부 (22:00~06:00 구간 근로)", example = "true")
+ private Boolean hasNightWork;
+
+ @Schema(description = "휴일근로 존재 여부 (주휴일/법정공휴일 근로)", example = "false")
+ private Boolean hasHolidayWork;
+}
diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationInput.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationInput.java
new file mode 100644
index 00000000..2edfb7d2
--- /dev/null
+++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationInput.java
@@ -0,0 +1,89 @@
+package com.concrete.buildup.domain.payroll.dto;
+
+import com.concrete.buildup.domain.contract.enums.EmpType;
+import com.concrete.buildup.domain.contract.enums.PayPeriod;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * 급여 계산 입력 DTO
+ *
+ * 급여 계산에 필요한 모든 입력 정보를 담는 DTO입니다.
+ * v1.1 급여 계산 로직의 입력 파라미터로 사용됩니다.
+ *
+ * @author Build-Up Team
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "급여 계산 입력 정보")
+public class PayrollCalculationInput {
+
+ // ========== 기본 정보 ==========
+
+ @Schema(description = "고용 형태 (DAILY: 일용직, PERMANENT: 상용직)", example = "DAILY", required = true)
+ private EmpType empType;
+
+ @Schema(description = "지급 주기 (DAILY: 일, WEEKLY: 주, MONTHLY: 월)", example = "DAILY", required = true)
+ private PayPeriod payPeriod;
+
+ @Schema(description = "시급", example = "10000", required = true)
+ private BigDecimal hourlyRate;
+
+ @Schema(description = "1일 소정근로시간", example = "8", required = true)
+ private BigDecimal dailyWorkHours;
+
+ @Schema(description = "근로일수", example = "20", required = true)
+ private Integer workDays;
+
+ // ========== 가산 시간 (발생 시간) ==========
+
+ @Schema(description = "연장근로시간", example = "10")
+ private BigDecimal overtimeHours;
+
+ @Schema(description = "야간근로시간", example = "5")
+ private BigDecimal nightHours;
+
+ @Schema(description = "휴일근로시간", example = "8")
+ private BigDecimal holidayHours;
+
+ // ========== 주휴수당 ==========
+
+ @Schema(description = "주휴수당 대상 여부", example = "true")
+ private Boolean weeklyHolidayEligible;
+
+ // ========== 4대보험 가입 여부 ==========
+
+ @Schema(description = "보험 가입 여부", required = true)
+ private InsuranceEligibility insurance;
+
+ // ========== 세금 관련 (상용직) ==========
+
+ @Schema(description = "부양가족 수 (상용직 세금 계산용)", example = "2")
+ private Integer dependents;
+
+ // ========== 요율 ==========
+
+ @Schema(description = "보험 요율 정보", required = true)
+ private InsuranceRates rates;
+
+ // ========== 가산수당 조건 ==========
+
+ @Schema(description = "가산수당 발생/지급 조건", required = true)
+ private OvertimeConditions overtimeConditions;
+
+ // ========== 일용직 세금 계산용 (추가) ==========
+
+ @Schema(description = "당월 근로일수 누적 (일용직 4대보험 적용 판단용)", example = "8")
+ private Integer monthlyWorkDaysAccumulated;
+
+ @Schema(description = "월 환산 소득 (일용직 4대보험 적용 판단용)", example = "2200000")
+ private BigDecimal monthlyEstimatedIncome;
+}
\ No newline at end of file
diff --git a/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationResult.java b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationResult.java
new file mode 100644
index 00000000..0ccc08ac
--- /dev/null
+++ b/src/main/java/com/concrete/buildup/domain/payroll/dto/PayrollCalculationResult.java
@@ -0,0 +1,122 @@
+package com.concrete.buildup.domain.payroll.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+/**
+ * 급여 계산 결과 DTO
+ *
+ * 급여 계산의 최종 결과를 담는 DTO입니다.
+ * 지급 항목, 공제 항목, 실지급액을 모두 포함합니다.
+ *
+ * @author Build-Up Team
+ * @since 1.0
+ */
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "급여 계산 결과")
+public class PayrollCalculationResult {
+
+ // ========== 지급 항목 ==========
+
+ @Schema(description = "기본급 (시급 × 총 통상시간)", example = "1600000")
+ private BigDecimal basePay;
+
+ @Schema(description = "연장수당 (시급 × 연장시간 × 1.5)", example = "150000")
+ private BigDecimal overtimePay;
+
+ @Schema(description = "야간수당 (시급 × 야간시간 × 0.5)", example = "50000")
+ private BigDecimal nightPay;
+
+ @Schema(description = "휴일수당 (시급 × 휴일시간 × 1.5)", example = "120000")
+ private BigDecimal holidayPay;
+
+ @Schema(description = "주휴수당 (일용직 조건 충족 시)", example = "80000")
+ private BigDecimal weeklyHolidayPay;
+
+ @Schema(description = "총 지급액 (모든 지급 항목의 합)", example = "2000000")
+ private BigDecimal totalPay;
+
+ // ========== 공제 항목 ==========
+
+ @Schema(description = "소득세 (일용직: 일별 계산, 상용직: 간이세액표)", example = "33000")
+ private BigDecimal incomeTax;
+
+ @Schema(description = "지방소득세 (주민세, 소득세의 10%)", example = "3300")
+ private BigDecimal residentTax;
+
+ @Schema(description = "고용보험 (과세대상금액 × 0.9%)", example = "18000")
+ private BigDecimal employmentInsurance;
+
+ @Schema(description = "건강보험 (과세대상금액 × 3.545%)", example = "70900")
+ private BigDecimal healthInsurance;
+
+ @Schema(description = "장기요양보험 (건강보험료 × 12.81%)", example = "9084")
+ private BigDecimal longTermCareInsurance;
+
+ @Schema(description = "국민연금 (과세대상금액 × 4.5%)", example = "90000")
+ private BigDecimal nationalPension;
+
+ @Schema(description = "총 공제액 (모든 공제 항목의 합)", example = "224284")
+ private BigDecimal totalDeduction;
+
+ // ========== 최종 금액 ==========
+
+ @Schema(description = "실지급액 (총 지급액 - 총 공제액)", example = "1775716")
+ private BigDecimal netPay;
+
+ // ========== 계산 상세 (디버깅/로그용) ==========
+
+ @Schema(description = "계산 상세 정보 (디버깅용)", example = "{\"totalWorkHours\": 160, \"taxableIncome\": 2000000}")
+ private Map calculationDetails;
+
+ /**
+ * 총 지급액 계산
+ *
+ * @return 모든 지급 항목의 합
+ */
+ public BigDecimal calculateTotalPay() {
+ BigDecimal total = BigDecimal.ZERO;
+ if (basePay != null) total = total.add(basePay);
+ if (overtimePay != null) total = total.add(overtimePay);
+ if (nightPay != null) total = total.add(nightPay);
+ if (holidayPay != null) total = total.add(holidayPay);
+ if (weeklyHolidayPay != null) total = total.add(weeklyHolidayPay);
+ return total;
+ }
+
+ /**
+ * 총 공제액 계산
+ *
+ * @return 모든 공제 항목의 합
+ */
+ public BigDecimal calculateTotalDeduction() {
+ BigDecimal total = BigDecimal.ZERO;
+ if (incomeTax != null) total = total.add(incomeTax);
+ if (residentTax != null) total = total.add(residentTax);
+ if (employmentInsurance != null) total = total.add(employmentInsurance);
+ if (healthInsurance != null) total = total.add(healthInsurance);
+ if (longTermCareInsurance != null) total = total.add(longTermCareInsurance);
+ if (nationalPension != null) total = total.add(nationalPension);
+ return total;
+ }
+
+ /**
+ * 실지급액 계산
+ *
+ * @return 총 지급액 - 총 공제액
+ */
+ public BigDecimal calculateNetPay() {
+ BigDecimal total = totalPay != null ? totalPay : calculateTotalPay();
+ BigDecimal deduction = totalDeduction != null ? totalDeduction : calculateTotalDeduction();
+ return total.subtract(deduction);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java b/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java
index d223a6df..2fa08a64 100644
--- a/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java
+++ b/src/main/java/com/concrete/buildup/domain/payroll/service/SalaryGenerationService.java
@@ -12,6 +12,11 @@
import com.concrete.buildup.domain.payroll.repository.PayslipItemRepository;
import com.concrete.buildup.domain.payroll.util.PayrollCalculator;
import com.concrete.buildup.domain.payroll.util.PayrollPdfGenerator;
+import com.concrete.buildup.domain.payroll.dto.PayrollCalculationInput;
+import com.concrete.buildup.domain.payroll.dto.PayrollCalculationResult;
+import com.concrete.buildup.domain.payroll.dto.OvertimeConditions;
+import com.concrete.buildup.domain.payroll.dto.InsuranceEligibility;
+import com.concrete.buildup.domain.payroll.dto.InsuranceRates;
import com.concrete.buildup.domain.upload.service.S3Service;
import com.concrete.buildup.domain.attendance.entity.Attendance;
import com.concrete.buildup.domain.attendance.repository.AttendanceRepository;
@@ -342,43 +347,90 @@ hourlyRate, new BigDecimal("40")
}
}
- // 급여 항목별 계산
- BigDecimal basePay = payrollCalculator.calculateBasePay(hourlyRate, workHours);
- BigDecimal nightPay = payrollCalculator.calculateNightWorkPay(hourlyRate, nightHours);
- BigDecimal overtimePay = payrollCalculator.calculateOvertimePay(hourlyRate, overtimeHours);
- BigDecimal holidayPay = payrollCalculator.calculateHolidayPay(hourlyRate, holidayHours);
- // weeklyHolidayPay는 이미 위에서 계산됨
+ // ========== v1.1 급여 계산 ==========
- // 총 지급액 계산
- BigDecimal totalPay = basePay.add(nightPay).add(overtimePay)
- .add(holidayPay).add(weeklyHolidayPay);
-
- // 비과세 소득 계산
- BigDecimal nonTaxIncome = payrollCalculator.calculateNonTaxableIncome(contractDetail);
-
- // 과세 소득 = 총 지급액 - 비과세 소득
- BigDecimal taxableIncome = totalPay.subtract(nonTaxIncome);
-
- // 세금 계산
- BigDecimal incomeTax = payrollCalculator.calculateIncomeTax(taxableIncome);
- BigDecimal residentTax = payrollCalculator.calculateResidentTax(taxableIncome);
-
- // 4대보험 계산
+ // 4대보험 가입 여부 조회
boolean hasNationalPension = Boolean.TRUE.equals(contractDetail.getIsNpsApplicable());
boolean hasHealthInsurance = Boolean.TRUE.equals(contractDetail.getIsNhiApplicable());
- boolean hasWorkersCompInsurance = Boolean.TRUE.equals(contractDetail.getIsWciApplicable());
boolean hasEmploymentInsurance = Boolean.TRUE.equals(contractDetail.getIsEoiApplicable());
- BigDecimal nationalPension = payrollCalculator.calculateNationalPension(totalPay, hasNationalPension);
- BigDecimal healthInsurance = payrollCalculator.calculateHealthInsurance(totalPay, hasHealthInsurance);
+ // 가산수당 발생 조건 판단
+ boolean exceedsDailyLimit = overtimeHours.compareTo(BigDecimal.ZERO) > 0;
+ boolean exceedsWeeklyLimit = false; // 주 40시간 초과 여부는 주차별 집계가 필요하므로 기본값 false
+ boolean hasNightWork = nightHours.compareTo(BigDecimal.ZERO) > 0;
+ boolean hasHolidayWork = holidayHours.compareTo(BigDecimal.ZERO) > 0;
+
+ // 일용직의 경우: monthlyWorkDaysAccumulated는 workDays 값 사용 (4대보험 가입 조건 판단용)
+ // 상용직의 경우: monthlyWorkDaysAccumulated는 사용하지 않으므로 0으로 설정
+ int workDaysCount = attendances.size();
+ int monthlyWorkDaysAccumulated = contract.getEmpType() == com.concrete.buildup.domain.contract.enums.EmpType.DAILY
+ ? workDaysCount // 일용직: 해당 기간의 근로일수 사용
+ : 0; // 상용직: 사용하지 않음
+
+ // v1.1 계산 입력 DTO 생성
+ PayrollCalculationInput calculationInput = PayrollCalculationInput.builder()
+ .empType(contract.getEmpType())
+ .payPeriod(payCycle)
+ .hourlyRate(hourlyRate)
+ .dailyWorkHours(new BigDecimal("8")) // 기본 일 근무시간 (계약상 기준)
+ .workDays(workDaysCount)
+ .overtimeHours(overtimeHours)
+ .nightHours(nightHours)
+ .holidayHours(holidayHours)
+ .weeklyHolidayEligible(true) // 주휴수당 자격 기본값 true
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(hasEmploymentInsurance)
+ .healthInsurance(hasHealthInsurance)
+ .nationalPension(hasNationalPension)
+ .build())
+ .dependents(0) // 부양가족 수 기본값 0 (향후 Employee 정보에서 가져올 수 있음)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5) // DEFAULT: 5인 이상 사업장으로 가정 (가산수당 의무 지급)
+ .autoPayOvertime(true) // DEFAULT: 연장수당 자율 지급
+ .autoPayNight(true) // DEFAULT: 야간수당 자율 지급
+ .autoPayHoliday(true) // DEFAULT: 휴일수당 자율 지급
+ .exceedsDailyLimit(exceedsDailyLimit)
+ .exceedsWeeklyLimit(exceedsWeeklyLimit)
+ .hasNightWork(hasNightWork)
+ .hasHolidayWork(hasHolidayWork)
+ .build())
+ .monthlyWorkDaysAccumulated(monthlyWorkDaysAccumulated) // 일용직: workDays 값 사용, 상용직: 0
+ .monthlyEstimatedIncome(BigDecimal.ZERO) // DEFAULT: 월 추정소득 (향후 계산 가능)
+ .build();
+
+ // v1.1 급여 계산 실행
+ PayrollCalculationResult calculationResult = payrollCalculator.calculate(calculationInput);
+
+ // 계산 결과 추출
+ BigDecimal basePay = calculationResult.getBasePay();
+ BigDecimal nightPay = calculationResult.getNightPay();
+ BigDecimal overtimePay = calculationResult.getOvertimePay();
+ BigDecimal holidayPay = calculationResult.getHolidayPay();
+ // v1.1에서 계산된 주휴수당 사용
+ weeklyHolidayPay = calculationResult.getWeeklyHolidayPay();
+ BigDecimal totalPay = calculationResult.getTotalPay();
+
+ // 공제 항목 추출
+ BigDecimal incomeTax = calculationResult.getIncomeTax();
+ BigDecimal residentTax = calculationResult.getResidentTax();
+ BigDecimal nationalPension = calculationResult.getNationalPension();
+ BigDecimal healthInsurance = calculationResult.getHealthInsurance();
+ BigDecimal longTermCareInsurance = calculationResult.getLongTermCareInsurance();
+ BigDecimal employmentInsurance = calculationResult.getEmploymentInsurance();
+
+ // 실수령액
+ BigDecimal netPay = calculationResult.getNetPay();
+
+ // 비과세 소득 (기존 로직 유지 - v1.1에는 없음)
+ BigDecimal nonTaxIncome = payrollCalculator.calculateNonTaxableIncome(contractDetail);
+
+ // 산재보험은 v1.1에 없으므로 기존 로직 사용
+ boolean hasWorkersCompInsurance = Boolean.TRUE.equals(contractDetail.getIsWciApplicable());
BigDecimal workersCompInsurance = payrollCalculator.calculateWorkersCompInsurance(totalPay, hasWorkersCompInsurance);
- BigDecimal employmentInsurance = payrollCalculator.calculateEmploymentInsurance(totalPay, hasEmploymentInsurance);
- // 실수령액 계산
- BigDecimal netPay = payrollCalculator.calculateNetPay(
- totalPay, incomeTax, residentTax,
- nationalPension, healthInsurance, workersCompInsurance, employmentInsurance
- );
+ log.info("[급여 생성] v1.1 계산 완료 - basePay: {}, overtimePay: {}, nightPay: {}, holidayPay: {}, weeklyHolidayPay: {}, totalPay: {}, netPay: {}",
+ basePay, overtimePay, nightPay, holidayPay, weeklyHolidayPay, totalPay, netPay);
// 5. Payroll 엔티티 생성
LocalDate searchDate = targetMonth.atDay(1); // 지급 기준월
diff --git a/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java b/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java
index 2fe0c378..01b59ea5 100644
--- a/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java
+++ b/src/main/java/com/concrete/buildup/domain/payroll/util/PayrollCalculator.java
@@ -1,11 +1,16 @@
package com.concrete.buildup.domain.payroll.util;
import com.concrete.buildup.domain.contract.entity.ContractDetail;
+import com.concrete.buildup.domain.contract.enums.EmpType;
+import com.concrete.buildup.domain.contract.enums.PayPeriod;
+import com.concrete.buildup.domain.payroll.dto.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
+import java.util.HashMap;
+import java.util.Map;
/**
* 급여 계산 유틸리티
@@ -33,7 +38,8 @@ public class PayrollCalculator {
private static final BigDecimal WORKERS_COMP_INSURANCE_RATE = new BigDecimal("0.007"); // 산재보험 0.7% (업종별 상이)
private static final BigDecimal EMPLOYMENT_INSURANCE_RATE = new BigDecimal("0.009"); // 고용보험 0.9%
- // 비과세 한도 (식대 등)
+ // 비과세 한도 (식대 등) - 향후 사용 예정
+ @SuppressWarnings("unused")
private static final BigDecimal NON_TAXABLE_LIMIT = new BigDecimal("200000"); // 월 20만원
/**
@@ -270,4 +276,325 @@ public BigDecimal calculateNetPay(BigDecimal totalPay, BigDecimal incomeTax, Big
return totalPay.subtract(totalDeduction).setScale(0, RoundingMode.FLOOR);
}
+
+ // ========================================
+ // v1.1 급여 계산 통합 메서드
+ // ========================================
+
+ /**
+ * v1.1 급여 계산 (통합 메서드)
+ *
+ * 모든 급여 계산 로직을 통합하여 처리합니다.
+ * 가산수당 조건 판정, 4대보험, 세금 계산을 포함합니다.
+ *
+ * @param input 급여 계산 입력 정보
+ * @return 급여 계산 결과
+ */
+ public PayrollCalculationResult calculate(PayrollCalculationInput input) {
+ log.info("v1.1 급여 계산 시작 - empType: {}, payPeriod: {}", input.getEmpType(), input.getPayPeriod());
+
+ Map details = new HashMap<>();
+
+ // 1) 기본급 계산
+ BigDecimal totalWorkHours = input.getDailyWorkHours().multiply(new BigDecimal(input.getWorkDays()));
+ BigDecimal basePay = input.getHourlyRate().multiply(totalWorkHours).setScale(0, RoundingMode.FLOOR);
+ details.put("totalWorkHours", totalWorkHours);
+ details.put("basePay", basePay);
+
+ // 2) 가산수당 지급 가능 여부 판정
+ boolean canPayOvertime = canPayOvertimeAllowance(input.getOvertimeConditions());
+ boolean canPayNight = canPayNightAllowance(input.getOvertimeConditions());
+ boolean canPayHoliday = canPayHolidayAllowance(input.getOvertimeConditions());
+
+ details.put("canPayOvertime", canPayOvertime);
+ details.put("canPayNight", canPayNight);
+ details.put("canPayHoliday", canPayHoliday);
+
+ // 3) 조건 미충족 시 시간 무효화
+ BigDecimal overtimeHours = canPayOvertime ? nvl(input.getOvertimeHours()) : BigDecimal.ZERO;
+ BigDecimal nightHours = canPayNight ? nvl(input.getNightHours()) : BigDecimal.ZERO;
+ BigDecimal holidayHours = canPayHoliday ? nvl(input.getHolidayHours()) : BigDecimal.ZERO;
+
+ // 4) 가산수당 계산 (중복가산 규칙 반영)
+ BigDecimal overtimePay = input.getHourlyRate().multiply(overtimeHours).multiply(new BigDecimal("1.5"))
+ .setScale(0, RoundingMode.FLOOR);
+ BigDecimal nightPay = input.getHourlyRate().multiply(nightHours).multiply(new BigDecimal("0.5"))
+ .setScale(0, RoundingMode.FLOOR);
+ BigDecimal holidayPay = input.getHourlyRate().multiply(holidayHours).multiply(new BigDecimal("1.5"))
+ .setScale(0, RoundingMode.FLOOR);
+
+ // 5) 주휴수당 계산
+ BigDecimal weeklyHolidayPay = BigDecimal.ZERO;
+ if (Boolean.TRUE.equals(input.getWeeklyHolidayEligible())) {
+ if (EmpType.DAILY.equals(input.getEmpType())) {
+ // 일용직: 하루 일당
+ weeklyHolidayPay = input.getHourlyRate().multiply(input.getDailyWorkHours())
+ .setScale(0, RoundingMode.FLOOR);
+ } else {
+ // 상용직: 주 4회 * (시급 * 일 근무시간)
+ BigDecimal dailyPay = input.getHourlyRate().multiply(input.getDailyWorkHours());
+ weeklyHolidayPay = dailyPay.multiply(new BigDecimal("4")).setScale(0, RoundingMode.FLOOR);
+ }
+ }
+
+ // 6) 총 지급액
+ BigDecimal totalPay = basePay.add(overtimePay).add(nightPay).add(holidayPay).add(weeklyHolidayPay);
+ BigDecimal taxableIncome = totalPay; // 비과세 미적용
+
+ details.put("taxableIncome", taxableIncome);
+
+ // 7) 4대보험 계산
+ InsuranceResult insurance = EmpType.DAILY.equals(input.getEmpType())
+ ? calculateDailyInsurance(input, taxableIncome)
+ : calculatePermanentInsurance(input, taxableIncome);
+
+ // 8) 세금 계산
+ TaxResult tax = EmpType.DAILY.equals(input.getEmpType())
+ ? calculateDailyTax(input, totalPay)
+ : calculatePermanentTax(input, taxableIncome);
+
+ // 9) 총 공제액 및 실지급액
+ BigDecimal totalDeduction = insurance.getTotal().add(tax.getTotal());
+ BigDecimal netPay = totalPay.subtract(totalDeduction).setScale(0, RoundingMode.FLOOR);
+
+ log.info("v1.1 급여 계산 완료 - totalPay: {}, totalDeduction: {}, netPay: {}",
+ totalPay, totalDeduction, netPay);
+
+ return PayrollCalculationResult.builder()
+ .basePay(basePay)
+ .overtimePay(overtimePay)
+ .nightPay(nightPay)
+ .holidayPay(holidayPay)
+ .weeklyHolidayPay(weeklyHolidayPay)
+ .totalPay(totalPay)
+ .incomeTax(tax.getIncomeTax())
+ .residentTax(tax.getResidentTax())
+ .employmentInsurance(insurance.getEmploymentInsurance())
+ .healthInsurance(insurance.getHealthInsurance())
+ .longTermCareInsurance(insurance.getLongTermCareInsurance())
+ .nationalPension(insurance.getNationalPension())
+ .totalDeduction(totalDeduction)
+ .netPay(netPay)
+ .calculationDetails(details)
+ .build();
+ }
+
+ /**
+ * 연장수당 지급 가능 여부 판정
+ */
+ private boolean canPayOvertimeAllowance(OvertimeConditions conditions) {
+ boolean isMandatory = conditions.getTotalEmployees() >= 5;
+ boolean meetsCondition = Boolean.TRUE.equals(conditions.getExceedsDailyLimit())
+ || Boolean.TRUE.equals(conditions.getExceedsWeeklyLimit());
+
+ if (isMandatory) {
+ return meetsCondition;
+ } else {
+ return Boolean.TRUE.equals(conditions.getAutoPayOvertime()) && meetsCondition;
+ }
+ }
+
+ /**
+ * 야간수당 지급 가능 여부 판정
+ */
+ private boolean canPayNightAllowance(OvertimeConditions conditions) {
+ boolean isMandatory = conditions.getTotalEmployees() >= 5;
+ boolean hasNightWork = Boolean.TRUE.equals(conditions.getHasNightWork());
+
+ if (isMandatory) {
+ return hasNightWork;
+ } else {
+ return Boolean.TRUE.equals(conditions.getAutoPayNight()) && hasNightWork;
+ }
+ }
+
+ /**
+ * 휴일수당 지급 가능 여부 판정
+ */
+ private boolean canPayHolidayAllowance(OvertimeConditions conditions) {
+ boolean isMandatory = conditions.getTotalEmployees() >= 5;
+ boolean hasHolidayWork = Boolean.TRUE.equals(conditions.getHasHolidayWork());
+
+ if (isMandatory) {
+ return hasHolidayWork;
+ } else {
+ return Boolean.TRUE.equals(conditions.getAutoPayHoliday()) && hasHolidayWork;
+ }
+ }
+
+ /**
+ * 일용직 4대보험 계산
+ */
+ private InsuranceResult calculateDailyInsurance(PayrollCalculationInput input, BigDecimal taxableIncome) {
+ InsuranceEligibility eligibility = input.getInsurance();
+ InsuranceRates rates = input.getRates();
+
+ // 월 근로일수 조회 (일용직 월급의 경우 8일 미만이면 전체 4대보험 미가입)
+ Integer monthlyDays = input.getMonthlyWorkDaysAccumulated() != null ? input.getMonthlyWorkDaysAccumulated() : 0;
+
+ // 고용보험: 일용직 월급의 경우 월 8일 미만이면 미가입
+ boolean employmentApplicable = Boolean.TRUE.equals(eligibility.getEmploymentInsurance());
+ if (input.getPayPeriod() == PayPeriod.MONTHLY && monthlyDays < 8) {
+ employmentApplicable = false;
+ }
+ BigDecimal employmentIns = employmentApplicable
+ ? taxableIncome.multiply(rates.getEmploymentInsurance()).setScale(0, RoundingMode.FLOOR)
+ : BigDecimal.ZERO;
+
+ // 월 소득 기준 판정
+ BigDecimal monthlyIncome = input.getMonthlyEstimatedIncome() != null ? input.getMonthlyEstimatedIncome() : BigDecimal.ZERO;
+
+ // 일용직: 월 8일 미만이면 건강보험과 국민연금 모두 미가입
+ // 단, 국민연금은 월소득 220만원 이상이면 월 8일 미만이어도 가입 가능 (단, 월 8일 미만이면 무조건 미가입으로 처리)
+ boolean healthApplicable = Boolean.TRUE.equals(eligibility.getHealthInsurance()) && monthlyDays >= 8;
+ boolean pensionApplicable = Boolean.TRUE.equals(eligibility.getNationalPension()) && monthlyDays >= 8;
+
+ // 건강보험 및 장기요양: 월소득이 있으면 월소득 기준, 없으면 당월 총 지급액 기준
+ // 단, 일용직 월급의 경우 총지급액 기준으로 계산
+ BigDecimal insuranceBase = taxableIncome; // 일용직은 총지급액 기준
+ BigDecimal healthIns = healthApplicable
+ ? insuranceBase.multiply(rates.getHealthInsurance()).setScale(0, RoundingMode.FLOOR)
+ : BigDecimal.ZERO;
+ BigDecimal longTermCareIns = healthApplicable
+ ? healthIns.multiply(rates.getLongTermCare()).divide(new BigDecimal("10"), 0, RoundingMode.FLOOR).multiply(new BigDecimal("10"))
+ : BigDecimal.ZERO;
+
+ // 국민연금: 일용직은 총지급액 기준
+ BigDecimal nationalPension = pensionApplicable
+ ? insuranceBase.multiply(rates.getNationalPension()).setScale(0, RoundingMode.FLOOR)
+ : BigDecimal.ZERO;
+
+ return new InsuranceResult(employmentIns, healthIns, longTermCareIns, nationalPension);
+ }
+
+ /**
+ * 상용직 4대보험 계산
+ */
+ private InsuranceResult calculatePermanentInsurance(PayrollCalculationInput input, BigDecimal taxableIncome) {
+ InsuranceRates rates = input.getRates();
+
+ BigDecimal employmentIns = taxableIncome.multiply(rates.getEmploymentInsurance()).setScale(0, RoundingMode.FLOOR);
+ BigDecimal healthIns = taxableIncome.multiply(rates.getHealthInsurance()).setScale(0, RoundingMode.FLOOR);
+ BigDecimal longTermCareIns = healthIns.multiply(rates.getLongTermCare()).divide(new BigDecimal("10"), 0, RoundingMode.FLOOR).multiply(new BigDecimal("10"));
+ BigDecimal nationalPension = taxableIncome.multiply(rates.getNationalPension()).setScale(0, RoundingMode.FLOOR);
+
+ return new InsuranceResult(employmentIns, healthIns, longTermCareIns, nationalPension);
+ }
+
+ /**
+ * 일용직 세금 계산 (일별 계산)
+ */
+ private TaxResult calculateDailyTax(PayrollCalculationInput input, BigDecimal totalPay) {
+ // 근로일수가 0이거나 null이면 세금 0원 반환
+ if (input.getWorkDays() == null || input.getWorkDays() == 0) {
+ return new TaxResult(BigDecimal.ZERO, BigDecimal.ZERO);
+ }
+
+ // 일급 = 총 지급액 / 근로일수
+ BigDecimal dailyPay = totalPay.divide(new BigDecimal(input.getWorkDays()), 0, RoundingMode.FLOOR);
+
+ BigDecimal totalIncomeTax = BigDecimal.ZERO;
+ BigDecimal totalResidentTax = BigDecimal.ZERO;
+
+ // 각 근로일에 대해 세금 계산
+ for (int i = 0; i < input.getWorkDays(); i++) {
+ // 과세표준 = max(0, 일급 - 150,000)
+ BigDecimal taxBase = dailyPay.subtract(new BigDecimal("150000")).max(BigDecimal.ZERO);
+
+ // 산출세액 = 과세표준 × 6%
+ BigDecimal calculatedTax = taxBase.multiply(new BigDecimal("0.06")).setScale(0, RoundingMode.FLOOR);
+
+ // 세액공제 = 산출세액 × 55%
+ BigDecimal taxCredit = calculatedTax.multiply(new BigDecimal("0.55")).setScale(0, RoundingMode.FLOOR);
+
+ // 결정세액 = 산출세액 - 세액공제
+ BigDecimal finalIncomeTax = calculatedTax.subtract(taxCredit);
+
+ // 지방소득세 = 결정세액 × 10%
+ BigDecimal residentTax = finalIncomeTax.multiply(new BigDecimal("0.10")).setScale(0, RoundingMode.FLOOR);
+
+ // 총 세금
+ BigDecimal dailyTax = finalIncomeTax.add(residentTax);
+
+ // 1,000원 미만 면제
+ if (dailyTax.compareTo(new BigDecimal("1000")) < 0) {
+ dailyTax = BigDecimal.ZERO;
+ finalIncomeTax = BigDecimal.ZERO;
+ residentTax = BigDecimal.ZERO;
+ }
+
+ totalIncomeTax = totalIncomeTax.add(finalIncomeTax);
+ totalResidentTax = totalResidentTax.add(residentTax);
+ }
+
+ return new TaxResult(totalIncomeTax, totalResidentTax);
+ }
+
+ /**
+ * 상용직 세금 계산 (간이세액표 - 현재는 3.3% 적용)
+ */
+ private TaxResult calculatePermanentTax(PayrollCalculationInput input, BigDecimal taxableIncome) {
+ // TODO: 간이세액표 적용 (현재는 단순 3.3%)
+ BigDecimal incomeTax = taxableIncome.multiply(new BigDecimal("0.033")).setScale(0, RoundingMode.FLOOR);
+ BigDecimal residentTax = incomeTax.multiply(new BigDecimal("0.10")).setScale(0, RoundingMode.FLOOR);
+
+ return new TaxResult(incomeTax, residentTax);
+ }
+
+ /**
+ * Null-safe BigDecimal 반환
+ */
+ @SuppressWarnings("unused") // calculate 메서드에서 사용됨
+ private BigDecimal nvl(BigDecimal value) {
+ return value != null ? value : BigDecimal.ZERO;
+ }
+
+ // ========== 내부 DTO 클래스 ==========
+
+ /**
+ * 보험 계산 결과
+ */
+ private static class InsuranceResult {
+ private final BigDecimal employmentInsurance;
+ private final BigDecimal healthInsurance;
+ private final BigDecimal longTermCareInsurance;
+ private final BigDecimal nationalPension;
+
+ public InsuranceResult(BigDecimal employmentInsurance, BigDecimal healthInsurance,
+ BigDecimal longTermCareInsurance, BigDecimal nationalPension) {
+ this.employmentInsurance = employmentInsurance;
+ this.healthInsurance = healthInsurance;
+ this.longTermCareInsurance = longTermCareInsurance;
+ this.nationalPension = nationalPension;
+ }
+
+ public BigDecimal getEmploymentInsurance() { return employmentInsurance; }
+ public BigDecimal getHealthInsurance() { return healthInsurance; }
+ public BigDecimal getLongTermCareInsurance() { return longTermCareInsurance; }
+ public BigDecimal getNationalPension() { return nationalPension; }
+
+ public BigDecimal getTotal() {
+ return employmentInsurance.add(healthInsurance).add(longTermCareInsurance).add(nationalPension);
+ }
+ }
+
+ /**
+ * 세금 계산 결과
+ */
+ private static class TaxResult {
+ private final BigDecimal incomeTax;
+ private final BigDecimal residentTax;
+
+ public TaxResult(BigDecimal incomeTax, BigDecimal residentTax) {
+ this.incomeTax = incomeTax;
+ this.residentTax = residentTax;
+ }
+
+ public BigDecimal getIncomeTax() { return incomeTax; }
+ public BigDecimal getResidentTax() { return residentTax; }
+
+ public BigDecimal getTotal() {
+ return incomeTax.add(residentTax);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/main/resources/templates/contract/contract-pdf.html b/src/main/resources/templates/contract/contract-pdf.html
index fe075779..e2cda895 100644
--- a/src/main/resources/templates/contract/contract-pdf.html
+++ b/src/main/resources/templates/contract/contract-pdf.html
@@ -54,7 +54,7 @@
}
.signature-page {
page-break-inside: avoid;
- margin-top: 455px;
+ margin-top: 475px;
}
.signature-area {
margin-top: 0;
diff --git a/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java b/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java
index 4c315d41..5d81cfc5 100644
--- a/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java
+++ b/src/test/java/com/concrete/buildup/domain/payroll/util/PayrollCalculatorTest.java
@@ -1,7 +1,15 @@
package com.concrete.buildup.domain.payroll.util;
+import com.concrete.buildup.domain.contract.enums.EmpType;
+import com.concrete.buildup.domain.contract.enums.PayPeriod;
+import com.concrete.buildup.domain.payroll.dto.InsuranceEligibility;
+import com.concrete.buildup.domain.payroll.dto.InsuranceRates;
+import com.concrete.buildup.domain.payroll.dto.OvertimeConditions;
+import com.concrete.buildup.domain.payroll.dto.PayrollCalculationInput;
+import com.concrete.buildup.domain.payroll.dto.PayrollCalculationResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
@@ -389,4 +397,509 @@ void realWorldScenario_PermanentWorker() {
// 실수령액: 3,135,000 - (103,455 + 9,405 + 141,075 + 111,135 + 21,945 + 28,215) = 2,719,770원
assertThat(netPay).isEqualByComparingTo(new BigDecimal("2719770"));
}
+
+ // ========================================
+ // v1.1 통합 계산 테스트
+ // ========================================
+
+ @Nested
+ @DisplayName("v1.1 통합 급여 계산 테스트")
+ class CalculateV1_1Test {
+
+ @Test
+ @DisplayName("일용직 일급 계산 - 기본 케이스 (가산수당 없음)")
+ void calculateDaily_Basic() {
+ // given - 일용직, 시급 12,000원, 8시간 근무, 가산수당 없음
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.DAILY)
+ .hourlyRate(new BigDecimal("12000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(1)
+ .overtimeHours(BigDecimal.ZERO)
+ .nightHours(BigDecimal.ZERO)
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(false)
+ .healthInsurance(false)
+ .nationalPension(false)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5)
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(false)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(false)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용
+ .monthlyEstimatedIncome(BigDecimal.ZERO)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then
+ // 기본급: 12,000 * 8 = 96,000원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000"));
+ assertThat(result.getOvertimePay()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getNightPay()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getHolidayPay()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getWeeklyHolidayPay()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("96000"));
+
+ // 일용직 일별 세금: (96,000 - 150,000) < 0 이므로 0원
+ assertThat(result.getIncomeTax()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getResidentTax()).isEqualByComparingTo(BigDecimal.ZERO);
+
+ // 4대보험: 미가입
+ assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getHealthInsurance()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getNationalPension()).isEqualByComparingTo(BigDecimal.ZERO);
+
+ // 실수령액: 96,000원
+ assertThat(result.getNetPay()).isEqualByComparingTo(new BigDecimal("96000"));
+ }
+
+ @Test
+ @DisplayName("일용직 일급 계산 - 15만원 초과 시 세금 발생")
+ void calculateDaily_WithTax() {
+ // given - 일용직, 시급 20,000원, 10시간 근무 (200,000원)
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.DAILY)
+ .hourlyRate(new BigDecimal("20000"))
+ .dailyWorkHours(new BigDecimal("10"))
+ .workDays(1)
+ .overtimeHours(BigDecimal.ZERO)
+ .nightHours(BigDecimal.ZERO)
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(false)
+ .healthInsurance(false)
+ .nationalPension(false)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5)
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(false)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(false)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용
+ .monthlyEstimatedIncome(BigDecimal.ZERO)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then
+ // 기본급: 20,000 * 10 = 200,000원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("200000"));
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("200000"));
+
+ // 일용직 세금 계산:
+ // 과세표준 = 200,000 - 150,000 = 50,000원
+ // 산출세액 = 50,000 × 6% = 3,000원
+ // 세액공제 = 3,000 × 55% = 1,650원
+ // 결정세액 (소득세) = 3,000 - 1,650 = 1,350원
+ // 지방소득세 = 1,350 × 10% = 135원
+ // 총 세금 = 1,350 + 135 = 1,485원 (1,000원 이상이므로 과세)
+ assertThat(result.getIncomeTax()).isEqualByComparingTo(new BigDecimal("1350"));
+ assertThat(result.getResidentTax()).isEqualByComparingTo(new BigDecimal("135"));
+
+ // 실수령액: 200,000 - 1,350 - 135 = 198,515원
+ assertThat(result.getNetPay()).isEqualByComparingTo(new BigDecimal("198515"));
+ }
+
+ @Test
+ @DisplayName("일용직 일급 계산 - 가산수당 포함 (연장 + 야간)")
+ void calculateDaily_WithOvertimeAndNight() {
+ // given - 일용직, 시급 12,000원, 통상 8시간 + 연장 2시간 + 야간 3시간
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.DAILY)
+ .hourlyRate(new BigDecimal("12000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(1)
+ .overtimeHours(new BigDecimal("2")) // 연장 2시간
+ .nightHours(new BigDecimal("3")) // 야간 3시간
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(false)
+ .healthInsurance(false)
+ .nationalPension(false)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5) // 5인 이상 사업장
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(true) // 일 8시간 초과
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(true)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용
+ .monthlyEstimatedIncome(BigDecimal.ZERO)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then
+ // 기본급: 12,000 * 8 = 96,000원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000"));
+
+ // 연장수당: 12,000 * 2 * 1.5 = 36,000원
+ assertThat(result.getOvertimePay()).isEqualByComparingTo(new BigDecimal("36000"));
+
+ // 야간수당: 12,000 * 3 * 0.5 = 18,000원
+ assertThat(result.getNightPay()).isEqualByComparingTo(new BigDecimal("18000"));
+
+ // 총 지급액: 96,000 + 36,000 + 18,000 = 150,000원
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("150000"));
+
+ // 세금: 150,000원 = 공제 기준선이므로 0원
+ assertThat(result.getIncomeTax()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getResidentTax()).isEqualByComparingTo(BigDecimal.ZERO);
+
+ // 실수령액: 150,000원
+ assertThat(result.getNetPay()).isEqualByComparingTo(new BigDecimal("150000"));
+ }
+
+ @Test
+ @DisplayName("일용직 일급 계산 - 5인 미만 사업장, 가산수당 자율 지급")
+ void calculateDaily_SmallBusiness_AutoPay() {
+ // given - 4인 사업장, 가산수당 자율 지급
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.DAILY)
+ .hourlyRate(new BigDecimal("12000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(1)
+ .overtimeHours(new BigDecimal("2"))
+ .nightHours(new BigDecimal("2"))
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(false)
+ .healthInsurance(false)
+ .nationalPension(false)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(4) // 5인 미만 사업장
+ .autoPayOvertime(true) // 연장수당 자율 지급
+ .autoPayNight(true) // 야간수당 자율 지급
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(true)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(true)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용
+ .monthlyEstimatedIncome(BigDecimal.ZERO)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then - 5인 미만이지만 자율 지급하므로 가산수당 지급
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000"));
+ assertThat(result.getOvertimePay()).isEqualByComparingTo(new BigDecimal("36000")); // 12,000 * 2 * 1.5
+ assertThat(result.getNightPay()).isEqualByComparingTo(new BigDecimal("12000")); // 12,000 * 2 * 0.5
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("144000"));
+ }
+
+ @Test
+ @DisplayName("일용직 일급 계산 - 5인 미만 사업장, 가산수당 미지급")
+ void calculateDaily_SmallBusiness_NoPay() {
+ // given - 4인 사업장, 가산수당 미지급
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.DAILY)
+ .hourlyRate(new BigDecimal("12000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(1)
+ .overtimeHours(new BigDecimal("2"))
+ .nightHours(new BigDecimal("2"))
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(false)
+ .healthInsurance(false)
+ .nationalPension(false)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(4) // 5인 미만 사업장
+ .autoPayOvertime(false) // 연장수당 미지급
+ .autoPayNight(false) // 야간수당 미지급
+ .autoPayHoliday(false)
+ .exceedsDailyLimit(true)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(true)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용
+ .monthlyEstimatedIncome(BigDecimal.ZERO)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then - 5인 미만이고 자율 미지급하므로 가산수당 0원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("96000"));
+ assertThat(result.getOvertimePay()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getNightPay()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("96000"));
+ }
+
+ @Test
+ @DisplayName("일용직 월급 계산 - 4대보험 가입 조건 충족 (월 8일, 월소득 220만원)")
+ void calculateDaily_Monthly_WithInsurance() {
+ // given - 일용직 월급, 월 10일 근무, 시급 25,000원 (월 200만원 예상)
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.MONTHLY)
+ .hourlyRate(new BigDecimal("25000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(10) // 월 10일 근무
+ .overtimeHours(BigDecimal.ZERO)
+ .nightHours(BigDecimal.ZERO)
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(true)
+ .healthInsurance(true)
+ .nationalPension(true)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5)
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(false)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(false)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(10) // 월 10일 근무
+ .monthlyEstimatedIncome(new BigDecimal("2500000")) // 월소득 250만원 (220만원 초과)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then
+ // 기본급: 25,000 * 8 * 10 = 2,000,000원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("2000000"));
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("2000000"));
+
+ // 4대보험: 월 8일 이상 + 월소득 220만원 이상 조건 충족
+ // 고용보험: 2,000,000 * 0.009 = 18,000원
+ assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(new BigDecimal("18000"));
+ // 건강보험: 2,000,000 * 0.03545 = 70,900원
+ assertThat(result.getHealthInsurance()).isEqualByComparingTo(new BigDecimal("70900"));
+ // 장기요양: 70,900 * 0.1281 = 9,082.29 → 9,080원 (10원 단위 절사)
+ assertThat(result.getLongTermCareInsurance()).isEqualByComparingTo(new BigDecimal("9080"));
+ // 국민연금: 2,000,000 * 0.045 = 90,000원
+ assertThat(result.getNationalPension()).isEqualByComparingTo(new BigDecimal("90000"));
+
+ // 총 공제액 계산 확인
+ BigDecimal totalDeduction = result.calculateTotalDeduction();
+ assertThat(totalDeduction.compareTo(BigDecimal.ZERO)).isGreaterThan(0);
+ }
+
+ @Test
+ @DisplayName("일용직 월급 계산 - 4대보험 미가입 (월 8일 미만)")
+ void calculateDaily_Monthly_NoInsurance_LessThan8Days() {
+ // given - 일용직 월급, 월 5일 근무
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.MONTHLY)
+ .hourlyRate(new BigDecimal("30000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(5) // 월 5일 근무 (8일 미만)
+ .overtimeHours(BigDecimal.ZERO)
+ .nightHours(BigDecimal.ZERO)
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(true)
+ .healthInsurance(true)
+ .nationalPension(true)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5)
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(false)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(false)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(5) // 월 5일 근무
+ .monthlyEstimatedIncome(new BigDecimal("3000000")) // 월소득은 충분하지만
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then - 월 8일 미만이므로 4대보험 미가입
+ assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getHealthInsurance()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getLongTermCareInsurance()).isEqualByComparingTo(BigDecimal.ZERO);
+ assertThat(result.getNationalPension()).isEqualByComparingTo(BigDecimal.ZERO);
+ }
+
+ @Test
+ @DisplayName("상용직 월급 계산 - 4대보험 전체 적용")
+ void calculatePermanent_Monthly_WithFullInsurance() {
+ // given - 상용직, 월 209시간, 시급 15,000원
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.PERMANENT)
+ .payPeriod(PayPeriod.MONTHLY)
+ .hourlyRate(new BigDecimal("15000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(20)
+ .overtimeHours(BigDecimal.ZERO)
+ .nightHours(BigDecimal.ZERO)
+ .holidayHours(BigDecimal.ZERO)
+ .weeklyHolidayEligible(true)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(true)
+ .healthInsurance(true)
+ .nationalPension(true)
+ .build())
+ .dependents(1) // 부양가족 1명
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(10)
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(false)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(false)
+ .hasHolidayWork(false)
+ .build())
+ .monthlyWorkDaysAccumulated(20)
+ .monthlyEstimatedIncome(new BigDecimal("3000000"))
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then
+ // 기본급: 15,000 * 8 * 20 = 2,400,000원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("2400000"));
+
+ // 주휴수당: 주 4회 * (15,000 * 8) = 480,000원
+ assertThat(result.getWeeklyHolidayPay()).isEqualByComparingTo(new BigDecimal("480000"));
+
+ // 총 지급액: 2,400,000 + 480,000 = 2,880,000원
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("2880000"));
+
+ // 상용직 세금: 간이세액표 (현재는 3.3% 적용)
+ // 2,880,000 * 0.033 = 95,040원
+ assertThat(result.getIncomeTax()).isEqualByComparingTo(new BigDecimal("95040"));
+ // 주민세: 95,040 * 0.1 = 9,504원
+ assertThat(result.getResidentTax()).isEqualByComparingTo(new BigDecimal("9504"));
+
+ // 4대보험
+ // 고용보험: 2,880,000 * 0.009 = 25,920원
+ assertThat(result.getEmploymentInsurance()).isEqualByComparingTo(new BigDecimal("25920"));
+ // 건강보험: 2,880,000 * 0.03545 = 102,096원
+ assertThat(result.getHealthInsurance()).isEqualByComparingTo(new BigDecimal("102096"));
+ // 장기요양: 102,096 * 0.1281 = 13,078.49 → 13,070원
+ assertThat(result.getLongTermCareInsurance()).isEqualByComparingTo(new BigDecimal("13070"));
+ // 국민연금: 2,880,000 * 0.045 = 129,600원
+ assertThat(result.getNationalPension()).isEqualByComparingTo(new BigDecimal("129600"));
+
+ // 실수령액 검증
+ BigDecimal expectedDeduction = new BigDecimal("95040")
+ .add(new BigDecimal("9504"))
+ .add(new BigDecimal("25920"))
+ .add(new BigDecimal("102096"))
+ .add(new BigDecimal("13070"))
+ .add(new BigDecimal("129600"));
+ BigDecimal expectedNetPay = new BigDecimal("2880000").subtract(expectedDeduction);
+ assertThat(result.getNetPay()).isEqualByComparingTo(expectedNetPay);
+ }
+
+ @Test
+ @DisplayName("휴일근로 + 야간근로 중복가산 계산")
+ void calculateDaily_HolidayAndNight_OverlappingAllowance() {
+ // given - 휴일에 야간근로 4시간
+ PayrollCalculationInput input = PayrollCalculationInput.builder()
+ .empType(EmpType.DAILY)
+ .payPeriod(PayPeriod.DAILY)
+ .hourlyRate(new BigDecimal("10000"))
+ .dailyWorkHours(new BigDecimal("8"))
+ .workDays(1)
+ .overtimeHours(BigDecimal.ZERO)
+ .nightHours(new BigDecimal("4")) // 야간 4시간
+ .holidayHours(new BigDecimal("4")) // 휴일 4시간 (야간과 중복)
+ .weeklyHolidayEligible(false)
+ .insurance(InsuranceEligibility.builder()
+ .employmentInsurance(false)
+ .healthInsurance(false)
+ .nationalPension(false)
+ .build())
+ .dependents(0)
+ .rates(InsuranceRates.defaultRates2025())
+ .overtimeConditions(OvertimeConditions.builder()
+ .totalEmployees(5)
+ .autoPayOvertime(true)
+ .autoPayNight(true)
+ .autoPayHoliday(true)
+ .exceedsDailyLimit(false)
+ .exceedsWeeklyLimit(false)
+ .hasNightWork(true)
+ .hasHolidayWork(true)
+ .build())
+ .monthlyWorkDaysAccumulated(1) // 일용직 일급: workDays 값 사용
+ .monthlyEstimatedIncome(BigDecimal.ZERO)
+ .build();
+
+ // when
+ PayrollCalculationResult result = calculator.calculate(input);
+
+ // then
+ // 기본급: 10,000 * 8 = 80,000원
+ assertThat(result.getBasePay()).isEqualByComparingTo(new BigDecimal("80000"));
+
+ // 휴일수당: 10,000 * 4 * 1.5 = 60,000원
+ assertThat(result.getHolidayPay()).isEqualByComparingTo(new BigDecimal("60000"));
+
+ // 야간수당: 10,000 * 4 * 0.5 = 20,000원 (휴일과 중복되는 시간에 추가)
+ assertThat(result.getNightPay()).isEqualByComparingTo(new BigDecimal("20000"));
+
+ // 총 지급액: 80,000 + 60,000 + 20,000 = 160,000원
+ assertThat(result.getTotalPay()).isEqualByComparingTo(new BigDecimal("160000"));
+ }
+ }
}
\ No newline at end of file