user()->name, self::ALLOWED_PAYROLL_USERS)) { return response()->json([ 'success' => false, 'message' => '급여관리는 관계자만 볼 수 있습니다.', ], 403); } return null; } /** * 급여 목록 조회 (HTMX → HTML / 일반 → JSON) */ public function index(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $payrolls = $this->payrollService->getPayrolls( $request->all(), $request->integer('per_page', 20) ); if ($request->header('HX-Request')) { return response(view('hr.payrolls.partials.table', compact('payrolls'))); } return response()->json([ 'success' => true, 'data' => $payrolls->items(), 'meta' => [ 'current_page' => $payrolls->currentPage(), 'last_page' => $payrolls->lastPage(), 'per_page' => $payrolls->perPage(), 'total' => $payrolls->total(), ], ]); } /** * 월간 통계 (HTMX → HTML / 일반 → JSON) */ public function stats(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $stats = $this->payrollService->getMonthlyStats( $request->integer('year') ?: null, $request->integer('month') ?: null ); if ($request->header('HX-Request')) { return response(view('hr.payrolls.partials.stats', compact('stats'))); } return response()->json([ 'success' => true, 'data' => $stats, ]); } /** * 급여 등록 */ public function store(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'user_id' => 'required|integer|exists:users,id', 'pay_year' => 'required|integer|min:2020|max:2100', 'pay_month' => 'required|integer|min:1|max:12', 'base_salary' => 'required|numeric|min:0', 'overtime_pay' => 'nullable|numeric|min:0', 'bonus' => 'nullable|numeric|min:0', 'allowances' => 'nullable|array', 'allowances.*.name' => 'required_with:allowances|string', 'allowances.*.amount' => 'required_with:allowances|numeric|min:0', 'deductions' => 'nullable|array', 'deductions.*.name' => 'required_with:deductions|string', 'deductions.*.amount' => 'required_with:deductions|numeric', 'deduction_overrides' => 'nullable|array', 'deduction_overrides.pension' => 'nullable|numeric', 'deduction_overrides.health_insurance' => 'nullable|numeric', 'deduction_overrides.long_term_care' => 'nullable|numeric', 'deduction_overrides.employment_insurance' => 'nullable|numeric', 'deduction_overrides.income_tax' => 'nullable|numeric', 'deduction_overrides.resident_tax' => 'nullable|numeric', 'note' => 'nullable|string|max:500', ]); try { $payroll = $this->payrollService->storePayroll($validated); return response()->json([ 'success' => true, 'message' => '급여가 등록되었습니다.', 'data' => $payroll, ], 201); } catch (\InvalidArgumentException $e) { return response()->json([ 'success' => false, 'message' => $e->getMessage(), ], 422); } catch (\Illuminate\Database\UniqueConstraintViolationException $e) { return response()->json([ 'success' => false, 'message' => '해당 직원의 동일 기간 급여가 이미 등록되어 있습니다.', ], 422); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 등록 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 수정 */ public function update(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'base_salary' => 'sometimes|required|numeric|min:0', 'overtime_pay' => 'nullable|numeric|min:0', 'bonus' => 'nullable|numeric|min:0', 'allowances' => 'nullable|array', 'allowances.*.name' => 'required_with:allowances|string', 'allowances.*.amount' => 'required_with:allowances|numeric|min:0', 'deductions' => 'nullable|array', 'deductions.*.name' => 'required_with:deductions|string', 'deductions.*.amount' => 'required_with:deductions|numeric', 'deduction_overrides' => 'nullable|array', 'deduction_overrides.pension' => 'nullable|numeric', 'deduction_overrides.health_insurance' => 'nullable|numeric', 'deduction_overrides.long_term_care' => 'nullable|numeric', 'deduction_overrides.employment_insurance' => 'nullable|numeric', 'deduction_overrides.income_tax' => 'nullable|numeric', 'deduction_overrides.resident_tax' => 'nullable|numeric', 'note' => 'nullable|string|max:500', ]); try { $payroll = $this->payrollService->updatePayroll($id, $validated); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여 정보를 찾을 수 없거나 수정할 수 없는 상태입니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여가 수정되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 수정 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 삭제 */ public function destroy(Request $request, int $id): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $result = $this->payrollService->deletePayroll($id); if (! $result) { return response()->json([ 'success' => false, 'message' => '급여 정보를 찾을 수 없거나 삭제할 수 없는 상태입니다.', ], 404); } if ($request->header('HX-Request')) { $payrolls = $this->payrollService->getPayrolls( $request->all(), $request->integer('per_page', 20) ); return response(view('hr.payrolls.partials.table', compact('payrolls'))); } return response()->json([ 'success' => true, 'message' => '급여가 삭제되었습니다.', ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 삭제 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 확정 */ public function confirm(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $payroll = $this->payrollService->confirmPayroll($id); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여를 확정할 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여가 확정되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 확정 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 확정 취소 */ public function unconfirm(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $payroll = $this->payrollService->unconfirmPayroll($id); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여 확정을 취소할 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여 확정이 취소되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 확정 취소 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 지급 처리 */ public function pay(Request $request, int $id): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } try { $payroll = $this->payrollService->payPayroll($id); if (! $payroll) { return response()->json([ 'success' => false, 'message' => '급여를 지급 처리할 수 없습니다.', ], 404); } return response()->json([ 'success' => true, 'message' => '급여가 지급 처리되었습니다.', 'data' => $payroll, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '급여 지급 처리 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 전월 급여 복사 등록 */ public function copyFromPrevious(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'pay_year' => 'required|integer|min:2020|max:2100', 'pay_month' => 'required|integer|min:1|max:12', ]); try { $result = $this->payrollService->copyFromPreviousMonth($validated['pay_year'], $validated['pay_month']); if (! empty($result['no_previous'])) { $prevYear = $validated['pay_month'] === 1 ? $validated['pay_year'] - 1 : $validated['pay_year']; $prevMonth = $validated['pay_month'] === 1 ? 12 : $validated['pay_month'] - 1; return response()->json([ 'success' => false, 'message' => "{$prevYear}년 {$prevMonth}월 급여 데이터가 없습니다.", ], 422); } return response()->json([ 'success' => true, 'message' => "전월 복사 완료: {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).", 'data' => $result, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '전월 복사 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 일괄 생성 */ public function bulkGenerate(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', ]); try { $result = $this->payrollService->bulkGenerate($validated['year'], $validated['month']); return response()->json([ 'success' => true, 'message' => "신규 {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).", 'data' => $result, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '일괄 생성 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 엑셀(XLSX) 내보내기 — 급여명세서 서식 */ public function export(Request $request): StreamedResponse|JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $payrolls = $this->payrollService->getExportData($request->all()); $year = $request->input('year', now()->year); $month = $request->input('month', now()->month); $filename = "급여명세서_{$year}년{$month}월_".now()->format('Ymd').'.xlsx'; $spreadsheet = new Spreadsheet; $sheet = $spreadsheet->getActiveSheet(); $sheet->setTitle('급여명세서'); $lastCol = 'O'; $headers = ['No', '사원명', '부서', '기본급', "고정연장\n근로수당", "식대\n(비과세)", '총지급액', '국민연금', '건강보험', "장기요양\n보험", '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액']; // ── Row 1: 제목 ── $sheet->mergeCells("A1:{$lastCol}1"); $sheet->setCellValue('A1', "< {$year}년도 {$month}월 급여명세서 >"); $sheet->getStyle('A1')->applyFromArray([ 'font' => ['bold' => true, 'size' => 14], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getRowDimension(1)->setRowHeight(30); // ── Row 2: 헤더 ── foreach ($headers as $colIdx => $header) { $cell = chr(65 + $colIdx).'2'; $sheet->setCellValue($cell, $header); } $sheet->getStyle("A2:{$lastCol}2")->applyFromArray([ 'font' => ['bold' => true, 'size' => 10, 'color' => ['argb' => 'FFFFFFFF']], 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF1F3864']], 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]], ]); $sheet->getRowDimension(2)->setRowHeight(36); // ── Row 3~: 데이터 ── $dataStartRow = 3; $row = $dataStartRow; $moneyColumns = ['D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O']; foreach ($payrolls as $idx => $payroll) { $profile = $payroll->user?->tenantProfiles?->first(); $displayName = $profile?->display_name ?? $payroll->user?->name ?? '-'; $department = $profile?->department?->name ?? '-'; $sheet->setCellValue("A{$row}", $idx + 1); $sheet->setCellValue("B{$row}", $displayName); $sheet->setCellValue("C{$row}", $department); $sheet->setCellValue("D{$row}", (int) $payroll->base_salary); $sheet->setCellValue("E{$row}", (int) $payroll->overtime_pay); $sheet->setCellValue("F{$row}", (int) $payroll->bonus); $sheet->setCellValue("G{$row}", (int) $payroll->gross_salary); $sheet->setCellValue("H{$row}", (int) $payroll->pension); $sheet->setCellValue("I{$row}", (int) $payroll->health_insurance); $sheet->setCellValue("J{$row}", (int) $payroll->long_term_care); $sheet->setCellValue("K{$row}", (int) $payroll->employment_insurance); $sheet->setCellValue("L{$row}", (int) $payroll->income_tax); $sheet->setCellValue("M{$row}", (int) $payroll->resident_tax); $sheet->setCellValue("N{$row}", (int) $payroll->total_deductions); $sheet->setCellValue("O{$row}", (int) $payroll->net_salary); $row++; } // 빈 행 채움 (최소 10행까지 테두리) $minEndRow = $dataStartRow + 9; while ($row <= $minEndRow) { $row++; } $lastDataRow = $row - 1; // ── 데이터 영역 스타일 ── $sheet->getStyle("A{$dataStartRow}:{$lastCol}{$lastDataRow}")->applyFromArray([ 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], 'font' => ['size' => 10], ]); // No 가운데 정렬 $sheet->getStyle("A{$dataStartRow}:A{$lastDataRow}") ->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); // 금액 서식: #,##0 + 오른쪽 정렬 foreach ($moneyColumns as $col) { $range = "{$col}{$dataStartRow}:{$col}{$lastDataRow}"; $sheet->getStyle($range)->getNumberFormat()->setFormatCode('#,##0'); $sheet->getStyle($range)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); } // ── 합계 행 ── $sumRow = $lastDataRow + 1; $sheet->mergeCells("A{$sumRow}:C{$sumRow}"); $sheet->setCellValue("A{$sumRow}", '합계'); foreach ($moneyColumns as $col) { $sheet->setCellValue( "{$col}{$sumRow}", "=SUM({$col}{$dataStartRow}:{$col}{$lastDataRow})" ); } $sheet->getStyle("A{$sumRow}:{$lastCol}{$sumRow}")->applyFromArray([ 'font' => ['bold' => true, 'size' => 10], 'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FFF2F2F2']], 'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]], 'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], ]); $sheet->getStyle("A{$sumRow}:C{$sumRow}") ->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER); foreach ($moneyColumns as $col) { $sheet->getStyle("{$col}{$sumRow}")->getNumberFormat()->setFormatCode('#,##0'); $sheet->getStyle("{$col}{$sumRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT); } // ── 열 너비 ── $widths = ['A' => 6, 'B' => 12, 'C' => 12]; foreach (range('D', 'O') as $col) { $widths[$col] = 13; } foreach ($widths as $col => $width) { $sheet->getColumnDimension($col)->setWidth($width); } // ── 응답 반환 ── return response()->streamDownload(function () use ($spreadsheet) { $writer = new Xlsx($spreadsheet); $writer->save('php://output'); $spreadsheet->disconnectWorksheets(); }, $filename, [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control' => 'max-age=0', ]); } /** * 급여 설정 조회 (HTMX → HTML / 일반 → JSON) */ public function settingsIndex(Request $request): JsonResponse|Response { if ($denied = $this->checkPayrollAccess()) { return $denied; } $settings = $this->payrollService->getSettings(); if ($request->header('HX-Request')) { return response(view('hr.payrolls.partials.settings', compact('settings'))); } return response()->json([ 'success' => true, 'data' => $settings, ]); } /** * 급여 설정 수정 */ public function settingsUpdate(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'health_insurance_rate' => 'nullable|numeric|min:0|max:100', 'long_term_care_rate' => 'nullable|numeric|min:0|max:100', 'pension_rate' => 'nullable|numeric|min:0|max:100', 'employment_insurance_rate' => 'nullable|numeric|min:0|max:100', 'pension_max_salary' => 'nullable|numeric|min:0', 'pension_min_salary' => 'nullable|numeric|min:0', 'pay_day' => 'nullable|integer|min:1|max:31', 'auto_calculate' => 'nullable|boolean', ]); try { $settings = $this->payrollService->updateSettings($validated); return response()->json([ 'success' => true, 'message' => '급여 설정이 저장되었습니다.', 'data' => $settings, ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '설정 저장 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 일반전표 자동 생성 */ public function generateJournalEntry(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $request->validate([ 'year' => 'required|integer|min:2020|max:2100', 'month' => 'required|integer|min:1|max:12', ]); $year = $request->integer('year'); $month = $request->integer('month'); $tenantId = session('selected_tenant_id', 1); $sourceKey = "payroll-{$year}-{$month}"; // 중복 체크 $existing = JournalEntry::forTenant($tenantId) ->where('source_type', 'payroll') ->where('source_key', $sourceKey) ->first(); if ($existing) { return response()->json([ 'success' => false, 'message' => "이미 {$month}월분 급여 전표가 존재합니다 ({$existing->entry_no})", ], 422); } // 해당월 급여 합산 $sums = Payroll::forTenant($tenantId) ->forPeriod($year, $month) ->selectRaw(' SUM(gross_salary) as total_gross, SUM(pension) as total_pension, SUM(health_insurance) as total_health, SUM(long_term_care) as total_ltc, SUM(employment_insurance) as total_emp, SUM(income_tax) as total_income_tax, SUM(resident_tax) as total_resident_tax, SUM(net_salary) as total_net ') ->first(); if (! $sums || (int) $sums->total_gross === 0) { return response()->json([ 'success' => false, 'message' => '해당 월 급여 데이터가 없습니다.', ], 422); } // 거래처 조회 $partnerNames = ['임직원', '건강보험연금', '건강보험건강', '건강보험고용', '강서세무서', '강서구청']; $partners = TradingPartner::forTenant($tenantId) ->whereIn('name', $partnerNames) ->pluck('id', 'name'); $missingPartners = array_diff($partnerNames, $partners->keys()->toArray()); if (! empty($missingPartners)) { return response()->json([ 'success' => false, 'message' => '거래처가 등록되어 있지 않습니다: '.implode(', ', $missingPartners), ], 422); } // 계정과목 조회 $accountCodes = AccountCode::whereIn('code', ['801', '207', '205']) ->where('is_active', true) ->pluck('name', 'code'); $missingCodes = array_diff(['801', '207', '205'], $accountCodes->keys()->toArray()); if (! empty($missingCodes)) { return response()->json([ 'success' => false, 'message' => '계정과목이 등록되어 있지 않습니다: '.implode(', ', $missingCodes), ], 422); } // 전표일자 (해당월 말일) $entryDate = Carbon::create($year, $month)->endOfMonth()->toDateString(); $monthLabel = "{$month}월분"; // 분개 행 구성 $lines = []; $lineNo = 1; // 1. 차변: 801 급여 / 임직원 $grossAmount = (int) $sums->total_gross; if ($grossAmount > 0) { $lines[] = [ 'dc_type' => 'debit', 'account_code' => '801', 'account_name' => $accountCodes['801'], 'trading_partner_id' => $partners['임직원'], 'trading_partner_name' => '임직원', 'debit_amount' => $grossAmount, 'credit_amount' => 0, 'description' => "{$monthLabel} 급여", 'line_no' => $lineNo++, ]; } // 2. 대변: 207 예수금 / 건강보험연금 — 국민연금 $pension = (int) $sums->total_pension; if ($pension > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '207', 'account_name' => $accountCodes['207'], 'trading_partner_id' => $partners['건강보험연금'], 'trading_partner_name' => '건강보험연금', 'debit_amount' => 0, 'credit_amount' => $pension, 'description' => '국민연금', 'line_no' => $lineNo++, ]; } // 3. 대변: 207 예수금 / 건강보험건강 — 건강보험 $health = (int) $sums->total_health; if ($health > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '207', 'account_name' => $accountCodes['207'], 'trading_partner_id' => $partners['건강보험건강'], 'trading_partner_name' => '건강보험건강', 'debit_amount' => 0, 'credit_amount' => $health, 'description' => '건강보험', 'line_no' => $lineNo++, ]; } // 4. 대변: 207 예수금 / 건강보험건강 — 장기요양보험 $ltc = (int) $sums->total_ltc; if ($ltc > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '207', 'account_name' => $accountCodes['207'], 'trading_partner_id' => $partners['건강보험건강'], 'trading_partner_name' => '건강보험건강', 'debit_amount' => 0, 'credit_amount' => $ltc, 'description' => '장기요양보험', 'line_no' => $lineNo++, ]; } // 5. 대변: 207 예수금 / 건강보험고용 — 고용보험 $emp = (int) $sums->total_emp; if ($emp > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '207', 'account_name' => $accountCodes['207'], 'trading_partner_id' => $partners['건강보험고용'], 'trading_partner_name' => '건강보험고용', 'debit_amount' => 0, 'credit_amount' => $emp, 'description' => '고용보험', 'line_no' => $lineNo++, ]; } // 6. 대변: 207 예수금 / 강서세무서 — 근로소득세 $incomeTax = (int) $sums->total_income_tax; if ($incomeTax > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '207', 'account_name' => $accountCodes['207'], 'trading_partner_id' => $partners['강서세무서'], 'trading_partner_name' => '강서세무서', 'debit_amount' => 0, 'credit_amount' => $incomeTax, 'description' => "{$monthLabel} 근로소득세", 'line_no' => $lineNo++, ]; } // 7. 대변: 207 예수금 / 강서구청 — 지방소득세 $residentTax = (int) $sums->total_resident_tax; if ($residentTax > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '207', 'account_name' => $accountCodes['207'], 'trading_partner_id' => $partners['강서구청'], 'trading_partner_name' => '강서구청', 'debit_amount' => 0, 'credit_amount' => $residentTax, 'description' => "{$monthLabel} 지방소득세", 'line_no' => $lineNo++, ]; } // 8. 대변: 205 미지급비용 / 임직원 — 급여 $netSalary = (int) $sums->total_net; if ($netSalary > 0) { $lines[] = [ 'dc_type' => 'credit', 'account_code' => '205', 'account_name' => $accountCodes['205'], 'trading_partner_id' => $partners['임직원'], 'trading_partner_name' => '임직원', 'debit_amount' => 0, 'credit_amount' => $netSalary, 'description' => "{$monthLabel} 급여", 'line_no' => $lineNo++, ]; } // 차대 균형 검증 $totalDebit = collect($lines)->sum('debit_amount'); $totalCredit = collect($lines)->sum('credit_amount'); if ($totalDebit !== $totalCredit || $totalDebit === 0) { return response()->json([ 'success' => false, 'message' => "차변({$totalDebit})과 대변({$totalCredit})이 일치하지 않습니다.", ], 422); } try { $entry = DB::transaction(function () use ($tenantId, $entryDate, $totalDebit, $totalCredit, $sourceKey, $monthLabel, $lines) { $entryNo = JournalEntry::generateEntryNo($tenantId, $entryDate); $entry = JournalEntry::create([ 'tenant_id' => $tenantId, 'entry_no' => $entryNo, 'entry_date' => $entryDate, 'entry_type' => 'general', 'description' => "{$monthLabel} 급여", 'total_debit' => $totalDebit, 'total_credit' => $totalCredit, 'status' => 'draft', 'source_type' => 'payroll', 'source_key' => $sourceKey, 'created_by_name' => auth()->user()?->name ?? '시스템', ]); foreach ($lines as $line) { JournalEntryLine::create(array_merge($line, [ 'tenant_id' => $tenantId, 'journal_entry_id' => $entry->id, ])); } return $entry; }); return response()->json([ 'success' => true, 'message' => "급여 전표가 생성되었습니다 ({$entry->entry_no})", 'data' => [ 'entry_no' => $entry->entry_no, 'entry_date' => $entry->entry_date->toDateString(), ], ]); } catch (\Throwable $e) { report($e); return response()->json([ 'success' => false, 'message' => '전표 생성 중 오류가 발생했습니다.', 'error' => config('app.debug') ? $e->getMessage() : null, ], 500); } } /** * 급여 계산 미리보기 (AJAX) */ public function calculate(Request $request): JsonResponse { if ($denied = $this->checkPayrollAccess()) { return $denied; } $validated = $request->validate([ 'base_salary' => 'required|numeric|min:0', 'overtime_pay' => 'nullable|numeric|min:0', 'bonus' => 'nullable|numeric|min:0', 'allowances' => 'nullable|array', 'deductions' => 'nullable|array', 'user_id' => 'nullable|integer', ]); $familyCount = 1; if (! empty($validated['user_id'])) { $familyCount = $this->payrollService->resolveFamilyCount($validated['user_id']); } $result = $this->payrollService->calculateAmounts($validated, null, $familyCount); return response()->json([ 'success' => true, 'data' => array_merge($result, ['family_count' => $familyCount]), ]); } }