diff --git a/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php b/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php new file mode 100644 index 00000000..09eb89d6 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php @@ -0,0 +1,145 @@ +user()->name, self::ALLOWED_PAYROLL_USERS)) { + return response()->json([ + 'success' => false, + 'message' => '급여관리는 관계자만 볼 수 있습니다.', + ], 403); + } + + return null; + } + + /** + * 사업소득 지급 목록 (HTMX → 스프레드시트 파셜) + */ + public function index(Request $request): JsonResponse|Response + { + if ($denied = $this->checkPayrollAccess()) { + return $denied; + } + + $year = $request->integer('year') ?: now()->year; + $month = $request->integer('month') ?: now()->month; + + $earners = $this->service->getActiveEarners(); + $payments = $this->service->getPayments($year, $month); + $paymentsByUser = $payments->keyBy('user_id'); + $stats = $this->service->getMonthlyStats($year, $month); + + if ($request->header('HX-Request')) { + return response( + view('hr.business-income-payments.partials.stats', compact('stats')). + ''. + view('hr.business-income-payments.partials.spreadsheet', compact('earners', 'paymentsByUser', 'year', 'month')) + ); + } + + return response()->json([ + 'success' => true, + 'data' => $payments, + 'stats' => $stats, + ]); + } + + /** + * 일괄 저장 + */ + public function bulkSave(Request $request): JsonResponse + { + if ($denied = $this->checkPayrollAccess()) { + return $denied; + } + + $validated = $request->validate([ + 'year' => 'required|integer|min:2020|max:2100', + 'month' => 'required|integer|min:1|max:12', + 'items' => 'required|array', + 'items.*.user_id' => 'required|integer', + 'items.*.gross_amount' => 'required|numeric|min:0', + 'items.*.service_content' => 'nullable|string|max:200', + 'items.*.payment_date' => 'nullable|date', + 'items.*.note' => 'nullable|string|max:500', + ]); + + $result = $this->service->bulkSave( + $validated['year'], + $validated['month'], + $validated['items'] + ); + + return response()->json([ + 'success' => true, + 'message' => "저장 {$result['saved']}건, 삭제 {$result['deleted']}건, 건너뜀 {$result['skipped']}건", + 'data' => $result, + ]); + } + + /** + * CSV 내보내기 + */ + public function export(Request $request): StreamedResponse|JsonResponse + { + if ($denied = $this->checkPayrollAccess()) { + return $denied; + } + + $year = $request->integer('year') ?: now()->year; + $month = $request->integer('month') ?: now()->month; + + $payments = $this->service->getExportData($year, $month); + $filename = "사업소득자임금대장_{$year}년{$month}월_".now()->format('Ymd').'.csv'; + + return response()->streamDownload(function () use ($payments) { + $file = fopen('php://output', 'w'); + fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM + + fputcsv($file, [ + '번호', '상호/성명', '사업자등록번호', '용역내용', + '지급총액', '소득세(3%)', '지방소득세(0.3%)', '공제합계', + '실지급액', '지급일자', '비고', '상태', + ]); + + foreach ($payments as $idx => $payment) { + $earnerName = $payment->user?->name ?? '-'; + + fputcsv($file, [ + $idx + 1, + $earnerName, + '', // 사업자등록번호는 별도 조회 필요 + $payment->service_content ?? '', + $payment->gross_amount, + $payment->income_tax, + $payment->local_income_tax, + $payment->total_deductions, + $payment->net_amount, + $payment->payment_date?->format('Y-m-d') ?? '', + $payment->note ?? '', + BusinessIncomePayment::STATUS_MAP[$payment->status] ?? $payment->status, + ]); + } + + fclose($file); + }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); + } +} diff --git a/app/Http/Controllers/HR/BusinessIncomePaymentController.php b/app/Http/Controllers/HR/BusinessIncomePaymentController.php new file mode 100644 index 00000000..8c51f5bf --- /dev/null +++ b/app/Http/Controllers/HR/BusinessIncomePaymentController.php @@ -0,0 +1,48 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('hr.business-income-payments.index')); + } + + if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) { + return view('hr.payrolls.restricted'); + } + + $year = $request->integer('year') ?: now()->year; + $month = $request->integer('month') ?: now()->month; + + $earners = $this->service->getActiveEarners(); + $payments = $this->service->getPayments($year, $month); + $paymentsByUser = $payments->keyBy('user_id'); + $stats = $this->service->getMonthlyStats($year, $month); + + return view('hr.business-income-payments.index', [ + 'earners' => $earners, + 'paymentsByUser' => $paymentsByUser, + 'stats' => $stats, + 'year' => $year, + 'month' => $month, + ]); + } +} diff --git a/app/Models/HR/BusinessIncomePayment.php b/app/Models/HR/BusinessIncomePayment.php new file mode 100644 index 00000000..37e6aa9a --- /dev/null +++ b/app/Models/HR/BusinessIncomePayment.php @@ -0,0 +1,192 @@ + 'int', + 'user_id' => 'int', + 'pay_year' => 'int', + 'pay_month' => 'int', + 'gross_amount' => 'decimal:0', + 'income_tax' => 'decimal:0', + 'local_income_tax' => 'decimal:0', + 'total_deductions' => 'decimal:0', + 'net_amount' => 'decimal:0', + 'payment_date' => 'date', + 'confirmed_at' => 'datetime', + 'paid_at' => 'datetime', + ]; + + protected $attributes = [ + 'status' => 'draft', + ]; + + // ========================================================================= + // 상수 + // ========================================================================= + + public const STATUS_DRAFT = 'draft'; + + public const STATUS_CONFIRMED = 'confirmed'; + + public const STATUS_PAID = 'paid'; + + public const STATUS_MAP = [ + 'draft' => '작성중', + 'confirmed' => '확정', + 'paid' => '지급완료', + ]; + + public const STATUS_COLORS = [ + 'draft' => 'amber', + 'confirmed' => 'blue', + 'paid' => 'emerald', + ]; + + /** 소득세율 3% */ + public const INCOME_TAX_RATE = 0.03; + + /** 지방소득세율 0.3% */ + public const LOCAL_TAX_RATE = 0.003; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function confirmer(): BelongsTo + { + return $this->belongsTo(User::class, 'confirmed_by'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // Accessor + // ========================================================================= + + public function getStatusLabelAttribute(): string + { + return self::STATUS_MAP[$this->status] ?? $this->status; + } + + public function getStatusColorAttribute(): string + { + return self::STATUS_COLORS[$this->status] ?? 'gray'; + } + + public function getPeriodLabelAttribute(): string + { + return sprintf('%d년 %d월', $this->pay_year, $this->pay_month); + } + + // ========================================================================= + // 상태 헬퍼 + // ========================================================================= + + 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; + } + + // ========================================================================= + // 세금 계산 (정적) + // ========================================================================= + + /** + * 지급총액으로 세금/공제/실지급액 계산 + * + * @return array{income_tax: int, local_income_tax: int, total_deductions: int, net_amount: int} + */ + public static function calculateTax(float $grossAmount): array + { + $incomeTax = (int) (floor($grossAmount * self::INCOME_TAX_RATE / 10) * 10); + $localIncomeTax = (int) (floor($grossAmount * self::LOCAL_TAX_RATE / 10) * 10); + $totalDeductions = $incomeTax + $localIncomeTax; + $netAmount = (int) max(0, $grossAmount - $totalDeductions); + + return [ + 'income_tax' => $incomeTax, + 'local_income_tax' => $localIncomeTax, + 'total_deductions' => $totalDeductions, + 'net_amount' => $netAmount, + ]; + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + public function scopeForTenant($query, ?int $tenantId = null) + { + $tenantId = $tenantId ?? session('selected_tenant_id', 1); + + return $query->where($this->table.'.tenant_id', $tenantId); + } + + public function scopeForPeriod($query, int $year, int $month) + { + return $query->where('pay_year', $year)->where('pay_month', $month); + } + + public function scopeWithStatus($query, string $status) + { + return $query->where('status', $status); + } +} diff --git a/app/Services/HR/BusinessIncomePaymentService.php b/app/Services/HR/BusinessIncomePaymentService.php new file mode 100644 index 00000000..a87943a5 --- /dev/null +++ b/app/Services/HR/BusinessIncomePaymentService.php @@ -0,0 +1,179 @@ +with('user:id,name') + ->forTenant($tenantId) + ->forPeriod($year, $month) + ->orderBy('id') + ->get(); + } + + /** + * 활성 사업소득자 목록 + */ + public function getActiveEarners(): Collection + { + $tenantId = session('selected_tenant_id', 1); + + return BusinessIncomeEarner::query() + ->with('user:id,name') + ->forTenant($tenantId) + ->activeEmployees() + ->orderBy('display_name') + ->get(); + } + + /** + * 일괄 저장 + * + * - 지급총액 > 0: upsert (신규 생성 또는 draft 수정) + * - 지급총액 == 0: draft면 삭제, confirmed/paid는 무시 + * - confirmed/paid 상태 레코드는 수정하지 않음 + */ + public function bulkSave(int $year, int $month, array $items): array + { + $tenantId = session('selected_tenant_id', 1); + $saved = 0; + $deleted = 0; + $skipped = 0; + + DB::transaction(function () use ($items, $tenantId, $year, $month, &$saved, &$deleted, &$skipped) { + foreach ($items as $item) { + $userId = (int) ($item['user_id'] ?? 0); + $grossAmount = (float) ($item['gross_amount'] ?? 0); + + if ($userId === 0) { + continue; + } + + // 기존 레코드 조회 (SoftDeletes 포함, 행 잠금) + $existing = BusinessIncomePayment::withTrashed() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('pay_year', $year) + ->where('pay_month', $month) + ->lockForUpdate() + ->first(); + + if ($grossAmount <= 0) { + // 지급총액 0: draft면 삭제 + if ($existing && ! $existing->trashed() && $existing->isEditable()) { + $existing->update(['deleted_by' => auth()->id()]); + $existing->delete(); + $deleted++; + } + + continue; + } + + // confirmed/paid 상태는 수정하지 않음 + if ($existing && ! $existing->trashed() && ! $existing->isEditable()) { + $skipped++; + + continue; + } + + $tax = BusinessIncomePayment::calculateTax($grossAmount); + + $data = [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'pay_year' => $year, + 'pay_month' => $month, + 'service_content' => $item['service_content'] ?? null, + 'gross_amount' => (int) $grossAmount, + 'income_tax' => $tax['income_tax'], + 'local_income_tax' => $tax['local_income_tax'], + 'total_deductions' => $tax['total_deductions'], + 'net_amount' => $tax['net_amount'], + 'payment_date' => ! empty($item['payment_date']) ? $item['payment_date'] : null, + 'note' => $item['note'] ?? null, + 'updated_by' => auth()->id(), + ]; + + if ($existing && $existing->trashed()) { + $existing->forceDelete(); + } + + if ($existing && ! $existing->trashed()) { + $existing->update($data); + } else { + $data['status'] = 'draft'; + $data['created_by'] = auth()->id(); + BusinessIncomePayment::create($data); + } + + $saved++; + } + }); + + return [ + 'saved' => $saved, + 'deleted' => $deleted, + 'skipped' => $skipped, + ]; + } + + /** + * 월간 통계 (통계 카드용) + */ + public function getMonthlyStats(int $year, int $month): array + { + $tenantId = session('selected_tenant_id', 1); + + $result = BusinessIncomePayment::query() + ->forTenant($tenantId) + ->forPeriod($year, $month) + ->select( + DB::raw('COUNT(*) as total_count'), + DB::raw('SUM(gross_amount) as total_gross'), + DB::raw('SUM(total_deductions) as total_deductions'), + DB::raw('SUM(net_amount) as total_net'), + DB::raw("SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft_count"), + DB::raw("SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) as confirmed_count"), + DB::raw("SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count"), + ) + ->first(); + + return [ + 'total_gross' => (int) ($result->total_gross ?? 0), + 'total_deductions' => (int) ($result->total_deductions ?? 0), + 'total_net' => (int) ($result->total_net ?? 0), + 'total_count' => (int) ($result->total_count ?? 0), + 'draft_count' => (int) ($result->draft_count ?? 0), + 'confirmed_count' => (int) ($result->confirmed_count ?? 0), + 'paid_count' => (int) ($result->paid_count ?? 0), + ]; + } + + /** + * CSV 내보내기 데이터 + */ + public function getExportData(int $year, int $month): Collection + { + $tenantId = session('selected_tenant_id', 1); + + return BusinessIncomePayment::query() + ->with('user:id,name') + ->forTenant($tenantId) + ->forPeriod($year, $month) + ->orderBy('id') + ->get(); + } +} diff --git a/resources/views/hr/business-income-payments/index.blade.php b/resources/views/hr/business-income-payments/index.blade.php new file mode 100644 index 00000000..de8876c0 --- /dev/null +++ b/resources/views/hr/business-income-payments/index.blade.php @@ -0,0 +1,235 @@ +@extends('layouts.app') + +@section('title', '사업소득자 임금대장') + +@section('content') +
| 구분 | +상호/성명 | +사업자등록번호 | +용역내용 | +지급총액 | +소득세(3%) | +지방소득세(0.3%) | +공제합계 | +실지급액 | +지급일자 | +비고 | +
|---|---|---|---|---|---|---|---|---|---|---|
| + {{ $idx + 1 }} + @if($isLocked) + {{ $statusLabel }} + @endif + | + + {{-- 상호/성명 --}} ++ {{ $earner->business_name ?: ($earner->user?->name ?? '-') }} + @if($earner->business_name && $earner->user?->name) + {{ $earner->user->name }} + @endif + | + + {{-- 사업자등록번호 --}} ++ {{ $earner->business_registration_number ?? '-' }} + | + + {{-- 용역내용 --}} ++ + | + + {{-- 지급총액 --}} ++ + | + + {{-- 소득세 (자동계산) --}} ++ {{ $payment ? number_format($payment->income_tax) : '0' }} + | + + {{-- 지방소득세 (자동계산) --}} ++ {{ $payment ? number_format($payment->local_income_tax) : '0' }} + | + + {{-- 공제합계 (자동계산) --}} ++ {{ $payment ? number_format($payment->total_deductions) : '0' }} + | + + {{-- 실지급액 (자동계산) --}} ++ {{ $payment ? number_format($payment->net_amount) : '0' }} + | + + {{-- 지급일자 --}} ++ + | + + {{-- 비고 --}} ++ + | +
| {{ $i + 1 }} | +- | +- | ++ | |||||||
| 합계 | ++ | + {{ number_format($paymentsByUser->sum('gross_amount')) }} + | ++ {{ number_format($paymentsByUser->sum('income_tax')) }} + | ++ {{ number_format($paymentsByUser->sum('local_income_tax')) }} + | ++ {{ number_format($paymentsByUser->sum('total_deductions')) }} + | ++ {{ number_format($paymentsByUser->sum('net_amount')) }} + | ++ | |||