tenantId(); $query = Payroll::query() ->where('tenant_id', $tenantId) ->with(['user:id,name,email', 'creator:id,name']); // 연도 필터 if (! empty($params['year'])) { $query->where('pay_year', $params['year']); } // 월 필터 if (! empty($params['month'])) { $query->where('pay_month', $params['month']); } // 사용자 필터 if (! empty($params['user_id'])) { $query->where('user_id', $params['user_id']); } // 상태 필터 if (! empty($params['status'])) { $query->where('status', $params['status']); } // 검색 (사용자명) if (! empty($params['search'])) { $query->whereHas('user', function ($q) use ($params) { $q->where('name', 'like', "%{$params['search']}%"); }); } // 정렬 $sortBy = $params['sort_by'] ?? 'pay_year'; $sortDir = $params['sort_dir'] ?? 'desc'; if ($sortBy === 'period') { $query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir); } else { $query->orderBy($sortBy, $sortDir); } $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 특정 연월 급여 요약 */ public function summary(int $year, int $month): array { $tenantId = $this->tenantId(); $stats = Payroll::query() ->where('tenant_id', $tenantId) ->where('pay_year', $year) ->where('pay_month', $month) ->selectRaw(' COUNT(*) as total_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as confirmed_count, SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as paid_count, SUM(gross_salary) as total_gross, SUM(total_deductions) as total_deductions, SUM(net_salary) as total_net ', [Payroll::STATUS_DRAFT, Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID]) ->first(); return [ 'year' => $year, 'month' => $month, 'total_count' => (int) $stats->total_count, 'draft_count' => (int) $stats->draft_count, 'confirmed_count' => (int) $stats->confirmed_count, 'paid_count' => (int) $stats->paid_count, 'total_gross' => (float) $stats->total_gross, 'total_deductions' => (float) $stats->total_deductions, 'total_net' => (float) $stats->total_net, ]; } /** * 급여 상세 */ public function show(int $id): Payroll { $tenantId = $this->tenantId(); return Payroll::query() ->where('tenant_id', $tenantId) ->with([ 'user:id,name,email', 'confirmer:id,name', 'withdrawal', 'creator:id,name', ]) ->findOrFail($id); } // ========================================================================= // 급여 생성/수정/삭제 // ========================================================================= /** * 급여 생성 */ public function store(array $data): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 중복 확인 $exists = Payroll::query() ->where('tenant_id', $tenantId) ->where('user_id', $data['user_id']) ->where('pay_year', $data['pay_year']) ->where('pay_month', $data['pay_month']) ->exists(); if ($exists) { throw new BadRequestHttpException(__('error.payroll.already_exists')); } // 금액 계산 $grossSalary = $this->calculateGross($data); $totalDeductions = $this->calculateDeductions($data); $netSalary = $grossSalary - $totalDeductions; return Payroll::create([ 'tenant_id' => $tenantId, 'user_id' => $data['user_id'], 'pay_year' => $data['pay_year'], 'pay_month' => $data['pay_month'], 'base_salary' => $data['base_salary'] ?? 0, 'overtime_pay' => $data['overtime_pay'] ?? 0, 'bonus' => $data['bonus'] ?? 0, 'allowances' => $data['allowances'] ?? null, 'gross_salary' => $grossSalary, 'income_tax' => $data['income_tax'] ?? 0, 'resident_tax' => $data['resident_tax'] ?? 0, 'health_insurance' => $data['health_insurance'] ?? 0, 'pension' => $data['pension'] ?? 0, 'employment_insurance' => $data['employment_insurance'] ?? 0, 'deductions' => $data['deductions'] ?? null, 'total_deductions' => $totalDeductions, 'net_salary' => $netSalary, 'status' => Payroll::STATUS_DRAFT, 'note' => $data['note'] ?? null, 'created_by' => $userId, 'updated_by' => $userId, ]); } /** * 급여 수정 */ public function update(int $id, array $data): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $payroll = Payroll::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $payroll->isEditable()) { throw new BadRequestHttpException(__('error.payroll.not_editable')); } // 연월 변경 시 중복 확인 $newYear = $data['pay_year'] ?? $payroll->pay_year; $newMonth = $data['pay_month'] ?? $payroll->pay_month; $newUserId = $data['user_id'] ?? $payroll->user_id; if ($newYear != $payroll->pay_year || $newMonth != $payroll->pay_month || $newUserId != $payroll->user_id) { $exists = Payroll::query() ->where('tenant_id', $tenantId) ->where('user_id', $newUserId) ->where('pay_year', $newYear) ->where('pay_month', $newMonth) ->where('id', '!=', $id) ->exists(); if ($exists) { throw new BadRequestHttpException(__('error.payroll.already_exists')); } } // 금액 업데이트 $updateData = array_merge($payroll->toArray(), $data); $grossSalary = $this->calculateGross($updateData); $totalDeductions = $this->calculateDeductions($updateData); $netSalary = $grossSalary - $totalDeductions; $payroll->fill([ 'user_id' => $data['user_id'] ?? $payroll->user_id, 'pay_year' => $data['pay_year'] ?? $payroll->pay_year, 'pay_month' => $data['pay_month'] ?? $payroll->pay_month, 'base_salary' => $data['base_salary'] ?? $payroll->base_salary, 'overtime_pay' => $data['overtime_pay'] ?? $payroll->overtime_pay, 'bonus' => $data['bonus'] ?? $payroll->bonus, 'allowances' => $data['allowances'] ?? $payroll->allowances, 'gross_salary' => $grossSalary, 'income_tax' => $data['income_tax'] ?? $payroll->income_tax, 'resident_tax' => $data['resident_tax'] ?? $payroll->resident_tax, 'health_insurance' => $data['health_insurance'] ?? $payroll->health_insurance, 'pension' => $data['pension'] ?? $payroll->pension, 'employment_insurance' => $data['employment_insurance'] ?? $payroll->employment_insurance, 'deductions' => $data['deductions'] ?? $payroll->deductions, 'total_deductions' => $totalDeductions, 'net_salary' => $netSalary, 'note' => $data['note'] ?? $payroll->note, 'updated_by' => $userId, ]); $payroll->save(); return $payroll->fresh(['user:id,name,email', 'creator:id,name']); } /** * 급여 삭제 */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $payroll = Payroll::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $payroll->isDeletable()) { throw new BadRequestHttpException(__('error.payroll.not_deletable')); } $payroll->deleted_by = $userId; $payroll->save(); $payroll->delete(); return true; } // ========================================================================= // 급여 확정/지급 // ========================================================================= /** * 급여 확정 */ public function confirm(int $id): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $payroll = Payroll::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $payroll->isConfirmable()) { throw new BadRequestHttpException(__('error.payroll.not_confirmable')); } $payroll->status = Payroll::STATUS_CONFIRMED; $payroll->confirmed_at = now(); $payroll->confirmed_by = $userId; $payroll->updated_by = $userId; $payroll->save(); return $payroll->fresh(['user:id,name,email', 'confirmer:id,name']); } /** * 급여 지급 처리 */ public function pay(int $id, ?int $withdrawalId = null): Payroll { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($id, $withdrawalId, $tenantId, $userId) { $payroll = Payroll::query() ->where('tenant_id', $tenantId) ->findOrFail($id); if (! $payroll->isPayable()) { throw new BadRequestHttpException(__('error.payroll.not_payable')); } // 출금 내역 연결 검증 if ($withdrawalId) { $withdrawal = Withdrawal::query() ->where('tenant_id', $tenantId) ->where('id', $withdrawalId) ->first(); if (! $withdrawal) { throw new BadRequestHttpException(__('error.payroll.invalid_withdrawal')); } } $payroll->status = Payroll::STATUS_PAID; $payroll->paid_at = now(); $payroll->withdrawal_id = $withdrawalId; $payroll->updated_by = $userId; $payroll->save(); return $payroll->fresh(['user:id,name,email', 'withdrawal']); }); } /** * 일괄 확정 */ public function bulkConfirm(int $year, int $month): int { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return Payroll::query() ->where('tenant_id', $tenantId) ->where('pay_year', $year) ->where('pay_month', $month) ->where('status', Payroll::STATUS_DRAFT) ->update([ 'status' => Payroll::STATUS_CONFIRMED, 'confirmed_at' => now(), 'confirmed_by' => $userId, 'updated_by' => $userId, ]); } // ========================================================================= // 급여명세서 // ========================================================================= /** * 급여명세서 데이터 */ public function payslip(int $id): array { $payroll = $this->show($id); // 수당 목록 $allowances = collect($payroll->allowances ?? [])->map(function ($item) { return [ 'name' => $item['name'] ?? '', 'amount' => (float) ($item['amount'] ?? 0), ]; })->toArray(); // 공제 목록 $deductions = collect($payroll->deductions ?? [])->map(function ($item) { return [ 'name' => $item['name'] ?? '', 'amount' => (float) ($item['amount'] ?? 0), ]; })->toArray(); return [ 'payroll' => $payroll, 'period' => $payroll->period_label, 'employee' => [ 'id' => $payroll->user->id, 'name' => $payroll->user->name, 'email' => $payroll->user->email, ], 'earnings' => [ 'base_salary' => (float) $payroll->base_salary, 'overtime_pay' => (float) $payroll->overtime_pay, 'bonus' => (float) $payroll->bonus, 'allowances' => $allowances, 'allowances_total' => (float) $payroll->allowances_total, 'gross_total' => (float) $payroll->gross_salary, ], 'deductions' => [ 'income_tax' => (float) $payroll->income_tax, 'resident_tax' => (float) $payroll->resident_tax, 'health_insurance' => (float) $payroll->health_insurance, 'pension' => (float) $payroll->pension, 'employment_insurance' => (float) $payroll->employment_insurance, 'other_deductions' => $deductions, 'other_total' => (float) $payroll->deductions_total, 'total' => (float) $payroll->total_deductions, ], 'net_salary' => (float) $payroll->net_salary, 'status' => $payroll->status, 'status_label' => $payroll->status_label, 'paid_at' => $payroll->paid_at?->toIso8601String(), ]; } // ========================================================================= // 급여 일괄 계산 // ========================================================================= /** * 급여 일괄 계산 (생성 또는 업데이트) */ public function calculate(int $year, int $month, ?array $userIds = null): Collection { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 급여 설정 가져오기 $settings = PayrollSetting::getOrCreate($tenantId); // 대상 사용자 조회 // TODO: 실제로는 직원 목록에서 급여 대상자를 조회해야 함 // 여기서는 기존 급여 데이터만 업데이트 return DB::transaction(function () use ($year, $month, $userIds, $tenantId, $userId, $settings) { $query = Payroll::query() ->where('tenant_id', $tenantId) ->where('pay_year', $year) ->where('pay_month', $month) ->where('status', Payroll::STATUS_DRAFT); if ($userIds) { $query->whereIn('user_id', $userIds); } $payrolls = $query->get(); foreach ($payrolls as $payroll) { // 4대보험 재계산 $baseSalary = (float) $payroll->base_salary; $healthInsurance = $settings->calculateHealthInsurance($baseSalary); $longTermCare = $settings->calculateLongTermCare($healthInsurance); $pension = $settings->calculatePension($baseSalary); $employmentInsurance = $settings->calculateEmploymentInsurance($baseSalary); // 건강보험에 장기요양보험 포함 $totalHealthInsurance = $healthInsurance + $longTermCare; $payroll->health_insurance = $totalHealthInsurance; $payroll->pension = $pension; $payroll->employment_insurance = $employmentInsurance; // 주민세 재계산 $payroll->resident_tax = $settings->calculateResidentTax($payroll->income_tax); // 총액 재계산 $payroll->total_deductions = $payroll->calculateTotalDeductions(); $payroll->net_salary = $payroll->calculateNetSalary(); $payroll->updated_by = $userId; $payroll->save(); } return $payrolls->fresh(['user:id,name,email']); }); } // ========================================================================= // 급여 설정 // ========================================================================= /** * 급여 설정 조회 */ public function getSettings(): PayrollSetting { $tenantId = $this->tenantId(); return PayrollSetting::getOrCreate($tenantId); } /** * 급여 설정 수정 */ public function updateSettings(array $data): PayrollSetting { $tenantId = $this->tenantId(); $settings = PayrollSetting::getOrCreate($tenantId); $settings->fill([ 'income_tax_rate' => $data['income_tax_rate'] ?? $settings->income_tax_rate, 'resident_tax_rate' => $data['resident_tax_rate'] ?? $settings->resident_tax_rate, 'health_insurance_rate' => $data['health_insurance_rate'] ?? $settings->health_insurance_rate, 'long_term_care_rate' => $data['long_term_care_rate'] ?? $settings->long_term_care_rate, 'pension_rate' => $data['pension_rate'] ?? $settings->pension_rate, 'employment_insurance_rate' => $data['employment_insurance_rate'] ?? $settings->employment_insurance_rate, 'pension_max_salary' => $data['pension_max_salary'] ?? $settings->pension_max_salary, 'pension_min_salary' => $data['pension_min_salary'] ?? $settings->pension_min_salary, 'pay_day' => $data['pay_day'] ?? $settings->pay_day, 'auto_calculate' => $data['auto_calculate'] ?? $settings->auto_calculate, 'allowance_types' => $data['allowance_types'] ?? $settings->allowance_types, 'deduction_types' => $data['deduction_types'] ?? $settings->deduction_types, ]); $settings->save(); return $settings; } // ========================================================================= // 헬퍼 메서드 // ========================================================================= /** * 총지급액 계산 */ private function calculateGross(array $data): float { $baseSalary = (float) ($data['base_salary'] ?? 0); $overtimePay = (float) ($data['overtime_pay'] ?? 0); $bonus = (float) ($data['bonus'] ?? 0); $allowancesTotal = 0; if (! empty($data['allowances'])) { $allowancesTotal = collect($data['allowances'])->sum('amount'); } return $baseSalary + $overtimePay + $bonus + $allowancesTotal; } /** * 총공제액 계산 */ private function calculateDeductions(array $data): float { $incomeTax = (float) ($data['income_tax'] ?? 0); $residentTax = (float) ($data['resident_tax'] ?? 0); $healthInsurance = (float) ($data['health_insurance'] ?? 0); $pension = (float) ($data['pension'] ?? 0); $employmentInsurance = (float) ($data['employment_insurance'] ?? 0); $deductionsTotal = 0; if (! empty($data['deductions'])) { $deductionsTotal = collect($data['deductions'])->sum('amount'); } return $incomeTax + $residentTax + $healthInsurance + $pension + $employmentInsurance + $deductionsTotal; } }