From 30973d17725f8db1bc3275a038d365d5ca4fba67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 27 Feb 2026 20:22:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[hr]=20=EC=82=AC=EC=97=85=EC=86=8C?= =?UTF-8?q?=EB=93=9D=EC=9E=90=20=EC=9E=84=EA=B8=88=EB=8C=80=EC=9E=A5=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BusinessIncomePayment 모델 (소득세3%/지방소득세0.3% 자동계산) - BusinessIncomePaymentService (일괄저장/통계/CSV내보내기) - 웹/API 컨트롤러 (ALLOWED_PAYROLL_USERS 접근 제한) - 스프레드시트 UI (인라인 편집, 실시간 세금 계산) - HTMX 연월 변경 갱신, CSV 내보내기 --- .../HR/BusinessIncomePaymentController.php | 145 +++++++++++ .../HR/BusinessIncomePaymentController.php | 48 ++++ app/Models/HR/BusinessIncomePayment.php | 192 ++++++++++++++ .../HR/BusinessIncomePaymentService.php | 179 +++++++++++++ .../business-income-payments/index.blade.php | 235 ++++++++++++++++++ .../partials/spreadsheet.blade.php | 158 ++++++++++++ .../partials/stats.blade.php | 23 ++ routes/api.php | 7 + routes/web.php | 5 + 9 files changed, 992 insertions(+) create mode 100644 app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php create mode 100644 app/Http/Controllers/HR/BusinessIncomePaymentController.php create mode 100644 app/Models/HR/BusinessIncomePayment.php create mode 100644 app/Services/HR/BusinessIncomePaymentService.php create mode 100644 resources/views/hr/business-income-payments/index.blade.php create mode 100644 resources/views/hr/business-income-payments/partials/spreadsheet.blade.php create mode 100644 resources/views/hr/business-income-payments/partials/stats.blade.php 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% +
+
+
+ + +
+
+ + {{-- 통계 카드 --}} +
+ @include('hr.business-income-payments.partials.stats', ['stats' => $stats]) +
+ + {{-- 스프레드시트 --}} +
+
+ @include('hr.business-income-payments.partials.spreadsheet', [ + 'earners' => $earners, + 'paymentsByUser' => $paymentsByUser, + 'year' => $year, + 'month' => $month, + ]) +
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php b/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php new file mode 100644 index 00000000..34add7fb --- /dev/null +++ b/resources/views/hr/business-income-payments/partials/spreadsheet.blade.php @@ -0,0 +1,158 @@ +{{-- 사업소득자 임금대장 스프레드시트 (HTMX로 갱신) --}} +@php + use App\Models\HR\BusinessIncomePayment; + + // 최소 10행 보장 + $rowCount = max(10, $earners->count()); +@endphp + + + + + + + + + + + + + + + + + + + + @foreach($earners as $idx => $earner) + @php + $payment = $paymentsByUser->get($earner->user_id); + $isLocked = $payment && !$payment->isEditable(); + $statusColor = $payment ? (BusinessIncomePayment::STATUS_COLORS[$payment->status] ?? 'gray') : ''; + $statusLabel = $payment ? (BusinessIncomePayment::STATUS_MAP[$payment->status] ?? '') : ''; + @endphp + + {{-- 구분 (번호) --}} + + + {{-- 상호/성명 --}} + + + {{-- 사업자등록번호 --}} + + + {{-- 용역내용 --}} + + + {{-- 지급총액 --}} + + + {{-- 소득세 (자동계산) --}} + + + {{-- 지방소득세 (자동계산) --}} + + + {{-- 공제합계 (자동계산) --}} + + + {{-- 실지급액 (자동계산) --}} + + + {{-- 지급일자 --}} + + + {{-- 비고 --}} + + + @endforeach + + {{-- 빈 행 채우기 (최소 10행) --}} + @for($i = $earners->count(); $i < 10; $i++) + + + + + + + @endfor + + + + + + + + + + + + + +
구분상호/성명사업자등록번호용역내용지급총액소득세(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')) }} +
+
diff --git a/resources/views/hr/business-income-payments/partials/stats.blade.php b/resources/views/hr/business-income-payments/partials/stats.blade.php new file mode 100644 index 00000000..c472bb7e --- /dev/null +++ b/resources/views/hr/business-income-payments/partials/stats.blade.php @@ -0,0 +1,23 @@ +{{-- 사업소득자 월간 통계 카드 (HTMX로 갱신) --}} +
+
+
총 지급액
+
{{ number_format($stats['total_gross']) }}
+
+
+
총 공제액
+
{{ number_format($stats['total_deductions']) }}
+
+
+
실지급 총액
+
{{ number_format($stats['total_net']) }}
+
+
+
대상 인원
+
{{ $stats['total_count'] }}명
+
+
+
미확정
+
{{ $stats['draft_count'] }}건
+
+
diff --git a/routes/api.php b/routes/api.php index 15ba42f0..58d04416 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1135,3 +1135,10 @@ Route::get('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'settingsIndex'])->name('index'); Route::put('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'settingsUpdate'])->name('update'); }); + +// 사업소득자 임금대장 API +Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/hr/business-income-payments')->name('api.admin.hr.business-income-payments.')->group(function () { + Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomePaymentController::class, 'export'])->name('export'); + Route::post('/bulk-save', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomePaymentController::class, 'bulkSave'])->name('bulk-save'); + Route::get('/', [\App\Http\Controllers\Api\Admin\HR\BusinessIncomePaymentController::class, 'index'])->name('index'); +}); diff --git a/routes/web.php b/routes/web.php index e3852dd1..941e6055 100644 --- a/routes/web.php +++ b/routes/web.php @@ -924,6 +924,11 @@ Route::prefix('payrolls')->name('payrolls.')->group(function () { Route::get('/', [\App\Http\Controllers\HR\PayrollController::class, 'index'])->name('index'); }); + + // 사업소득자 임금대장 + Route::prefix('business-income-payments')->name('business-income-payments.')->group(function () { + Route::get('/', [\App\Http\Controllers\HR\BusinessIncomePaymentController::class, 'index'])->name('index'); + }); }); /*