feat: 급여 관리 API 구현 (Phase 2: 3.2)

- 마이그레이션: payrolls, payroll_settings 테이블 생성
- 모델: Payroll (상태관리 draft→confirmed→paid), PayrollSetting
- 서비스: PayrollService (4대보험 계산, 급여명세서)
- 컨트롤러: PayrollController + FormRequest 5개
- API 엔드포인트 13개:
  - 급여 CRUD + confirm/pay/payslip
  - 일괄 계산/확정 (calculate, bulk-confirm)
  - 설정 관리 (settings/payroll)
- Swagger 문서: PayrollApi.php
- i18n: error.php, message.php, validation.php 키 추가
This commit is contained in:
2025-12-18 10:56:16 +09:00
parent b43796a558
commit 7089dd1e46
16 changed files with 2350 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 급여 모델
*
* @property int $id
* @property int $tenant_id
* @property int $user_id
* @property int $pay_year
* @property int $pay_month
* @property float $base_salary
* @property float $overtime_pay
* @property float $bonus
* @property array|null $allowances
* @property float $gross_salary
* @property float $income_tax
* @property float $resident_tax
* @property float $health_insurance
* @property float $pension
* @property float $employment_insurance
* @property array|null $deductions
* @property float $total_deductions
* @property float $net_salary
* @property string $status
* @property \Carbon\Carbon|null $confirmed_at
* @property int|null $confirmed_by
* @property \Carbon\Carbon|null $paid_at
* @property int|null $withdrawal_id
* @property string|null $note
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Payroll extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'payrolls';
protected $casts = [
'allowances' => 'array',
'deductions' => 'array',
'base_salary' => 'decimal:2',
'overtime_pay' => 'decimal:2',
'bonus' => 'decimal:2',
'gross_salary' => 'decimal:2',
'income_tax' => 'decimal:2',
'resident_tax' => 'decimal:2',
'health_insurance' => 'decimal:2',
'pension' => 'decimal:2',
'employment_insurance' => 'decimal:2',
'total_deductions' => 'decimal:2',
'net_salary' => 'decimal:2',
'confirmed_at' => 'datetime',
'paid_at' => 'datetime',
'pay_year' => 'integer',
'pay_month' => 'integer',
];
protected $fillable = [
'tenant_id',
'user_id',
'pay_year',
'pay_month',
'base_salary',
'overtime_pay',
'bonus',
'allowances',
'gross_salary',
'income_tax',
'resident_tax',
'health_insurance',
'pension',
'employment_insurance',
'deductions',
'total_deductions',
'net_salary',
'status',
'confirmed_at',
'confirmed_by',
'paid_at',
'withdrawal_id',
'note',
'created_by',
'updated_by',
'deleted_by',
];
protected $attributes = [
'status' => 'draft',
'base_salary' => 0,
'overtime_pay' => 0,
'bonus' => 0,
'gross_salary' => 0,
'income_tax' => 0,
'resident_tax' => 0,
'health_insurance' => 0,
'pension' => 0,
'employment_insurance' => 0,
'total_deductions' => 0,
'net_salary' => 0,
];
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_DRAFT = 'draft'; // 작성중
public const STATUS_CONFIRMED = 'confirmed'; // 확정
public const STATUS_PAID = 'paid'; // 지급완료
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_CONFIRMED,
self::STATUS_PAID,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 급여 대상 사용자
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* 확정자
*/
public function confirmer(): BelongsTo
{
return $this->belongsTo(User::class, 'confirmed_by');
}
/**
* 출금 내역
*/
public function withdrawal(): BelongsTo
{
return $this->belongsTo(Withdrawal::class, 'withdrawal_id');
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// 스코프
// =========================================================================
/**
* 특정 상태
*/
public function scopeWithStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 작성중
*/
public function scopeDraft($query)
{
return $query->where('status', self::STATUS_DRAFT);
}
/**
* 확정
*/
public function scopeConfirmed($query)
{
return $query->where('status', self::STATUS_CONFIRMED);
}
/**
* 지급완료
*/
public function scopePaid($query)
{
return $query->where('status', self::STATUS_PAID);
}
/**
* 특정 연월
*/
public function scopeForPeriod($query, int $year, int $month)
{
return $query->where('pay_year', $year)->where('pay_month', $month);
}
/**
* 특정 사용자
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 수정 가능 여부 (작성중 상태만)
*/
public function isEditable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 확정 가능 여부
*/
public function isConfirmable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 지급 가능 여부
*/
public function isPayable(): bool
{
return $this->status === self::STATUS_CONFIRMED;
}
/**
* 삭제 가능 여부 (작성중만)
*/
public function isDeletable(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
self::STATUS_DRAFT => '작성중',
self::STATUS_CONFIRMED => '확정',
self::STATUS_PAID => '지급완료',
default => $this->status,
};
}
/**
* 급여 기간 문자열
*/
public function getPeriodLabelAttribute(): string
{
return sprintf('%d년 %02d월', $this->pay_year, $this->pay_month);
}
/**
* 수당 합계
*/
public function getAllowancesTotalAttribute(): float
{
if (empty($this->allowances)) {
return 0;
}
return collect($this->allowances)->sum('amount');
}
/**
* 공제 합계 (JSON)
*/
public function getDeductionsTotalAttribute(): float
{
if (empty($this->deductions)) {
return 0;
}
return collect($this->deductions)->sum('amount');
}
/**
* 총지급액 계산
*/
public function calculateGrossSalary(): float
{
return $this->base_salary
+ $this->overtime_pay
+ $this->bonus
+ $this->allowances_total;
}
/**
* 총공제액 계산
*/
public function calculateTotalDeductions(): float
{
return $this->income_tax
+ $this->resident_tax
+ $this->health_insurance
+ $this->pension
+ $this->employment_insurance
+ $this->deductions_total;
}
/**
* 실수령액 계산
*/
public function calculateNetSalary(): float
{
return $this->gross_salary - $this->total_deductions;
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 급여 설정 모델
*
* @property int $id
* @property int $tenant_id
* @property float $income_tax_rate
* @property float $resident_tax_rate
* @property float $health_insurance_rate
* @property float $long_term_care_rate
* @property float $pension_rate
* @property float $employment_insurance_rate
* @property float $pension_max_salary
* @property float $pension_min_salary
* @property int $pay_day
* @property bool $auto_calculate
* @property array|null $allowance_types
* @property array|null $deduction_types
*/
class PayrollSetting extends Model
{
use BelongsToTenant;
protected $table = 'payroll_settings';
public $timestamps = true;
protected $casts = [
'income_tax_rate' => 'decimal:2',
'resident_tax_rate' => 'decimal:2',
'health_insurance_rate' => 'decimal:3',
'long_term_care_rate' => 'decimal:4',
'pension_rate' => 'decimal:3',
'employment_insurance_rate' => 'decimal:3',
'pension_max_salary' => 'decimal:2',
'pension_min_salary' => 'decimal:2',
'pay_day' => 'integer',
'auto_calculate' => 'boolean',
'allowance_types' => 'array',
'deduction_types' => 'array',
];
protected $fillable = [
'tenant_id',
'income_tax_rate',
'resident_tax_rate',
'health_insurance_rate',
'long_term_care_rate',
'pension_rate',
'employment_insurance_rate',
'pension_max_salary',
'pension_min_salary',
'pay_day',
'auto_calculate',
'allowance_types',
'deduction_types',
];
protected $attributes = [
'income_tax_rate' => 0,
'resident_tax_rate' => 10,
'health_insurance_rate' => 3.545,
'long_term_care_rate' => 0.9082,
'pension_rate' => 4.5,
'employment_insurance_rate' => 0.9,
'pension_max_salary' => 5900000,
'pension_min_salary' => 370000,
'pay_day' => 25,
'auto_calculate' => false,
];
// =========================================================================
// 기본 수당 유형
// =========================================================================
public const DEFAULT_ALLOWANCE_TYPES = [
['code' => 'meal', 'name' => '식대', 'is_taxable' => false],
['code' => 'transport', 'name' => '교통비', 'is_taxable' => false],
['code' => 'position', 'name' => '직책수당', 'is_taxable' => true],
['code' => 'skill', 'name' => '기술수당', 'is_taxable' => true],
['code' => 'family', 'name' => '가족수당', 'is_taxable' => true],
['code' => 'housing', 'name' => '주거수당', 'is_taxable' => true],
];
// =========================================================================
// 기본 공제 유형
// =========================================================================
public const DEFAULT_DEDUCTION_TYPES = [
['code' => 'loan', 'name' => '대출상환'],
['code' => 'union', 'name' => '조합비'],
['code' => 'savings', 'name' => '저축'],
['code' => 'etc', 'name' => '기타공제'],
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 수당 유형 목록 (기본값 포함)
*/
public function getAllowanceTypesWithDefaultAttribute(): array
{
return $this->allowance_types ?? self::DEFAULT_ALLOWANCE_TYPES;
}
/**
* 공제 유형 목록 (기본값 포함)
*/
public function getDeductionTypesWithDefaultAttribute(): array
{
return $this->deduction_types ?? self::DEFAULT_DEDUCTION_TYPES;
}
/**
* 건강보험료 계산
*/
public function calculateHealthInsurance(float $salary): float
{
return round($salary * ($this->health_insurance_rate / 100), 0);
}
/**
* 장기요양보험료 계산 (건강보험료의 %)
*/
public function calculateLongTermCare(float $healthInsurance): float
{
return round($healthInsurance * ($this->long_term_care_rate / 100), 0);
}
/**
* 국민연금 계산
*/
public function calculatePension(float $salary): float
{
// 기준소득월액 상/하한 적용
$standardSalary = min(max($salary, $this->pension_min_salary), $this->pension_max_salary);
return round($standardSalary * ($this->pension_rate / 100), 0);
}
/**
* 고용보험료 계산
*/
public function calculateEmploymentInsurance(float $salary): float
{
return round($salary * ($this->employment_insurance_rate / 100), 0);
}
/**
* 주민세 계산 (소득세의 10%)
*/
public function calculateResidentTax(float $incomeTax): float
{
return round($incomeTax * ($this->resident_tax_rate / 100), 0);
}
/**
* 테넌트별 설정 가져오기 또는 생성
*/
public static function getOrCreate(int $tenantId): self
{
return self::firstOrCreate(
['tenant_id' => $tenantId],
[
'allowance_types' => self::DEFAULT_ALLOWANCE_TYPES,
'deduction_types' => self::DEFAULT_DEDUCTION_TYPES,
]
);
}
}