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:
336
app/Models/Tenants/Payroll.php
Normal file
336
app/Models/Tenants/Payroll.php
Normal 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;
|
||||
}
|
||||
}
|
||||
191
app/Models/Tenants/PayrollSetting.php
Normal file
191
app/Models/Tenants/PayrollSetting.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user