From 5b79f5aeabe02764a6a8b5ddb4af7621138a1f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 19 Mar 2026 11:08:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[finance]=20=EA=B3=84=EC=A0=95=EB=B3=84?= =?UTF-8?q?=EC=9B=90=EC=9E=A5=C2=B7=EC=86=90=EC=9D=B5=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EB=A9=94=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계정별원장: 계정과목별 거래내역 조회, 월별소계/누계, 전표 드릴다운 - 손익계산서: 기간별 손익현황, 당기/전기 비교, 단위변환 - 일반전표+홈택스 분개 데이터 UNION ALL 통합 조회 - 회계/세무관리 메뉴에 일반전표입력과 전자세금계산서 사이 배치 --- .../Finance/AccountLedgerController.php | 214 ++++++++++++ .../Finance/IncomeStatementController.php | 272 ++++++++++++++++ .../views/finance/account-ledger.blade.php | 307 ++++++++++++++++++ .../views/finance/income-statement.blade.php | 236 ++++++++++++++ routes/web.php | 8 + 5 files changed, 1037 insertions(+) create mode 100644 app/Http/Controllers/Finance/AccountLedgerController.php create mode 100644 app/Http/Controllers/Finance/IncomeStatementController.php create mode 100644 resources/views/finance/account-ledger.blade.php create mode 100644 resources/views/finance/income-statement.blade.php diff --git a/app/Http/Controllers/Finance/AccountLedgerController.php b/app/Http/Controllers/Finance/AccountLedgerController.php new file mode 100644 index 00000000..9bc72208 --- /dev/null +++ b/app/Http/Controllers/Finance/AccountLedgerController.php @@ -0,0 +1,214 @@ +header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('finance.account-ledger')); + } + + return view('finance.account-ledger'); + } + + /** + * 계정별원장 데이터 조회 + */ + public function list(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d')); + $endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d')); + $accountCode = $request->input('account_code'); + + if (! $accountCode) { + return response()->json([ + 'account' => null, + 'period' => compact('startDate', 'endDate'), + 'monthly_data' => [], + 'grand_total' => ['debit' => 0, 'credit' => 0, 'balance' => 0], + ]); + } + + // 계정과목 정보 + $account = AccountCode::where('tenant_id', $tenantId) + ->where('code', $accountCode) + ->first(); + + if (! $account) { + return response()->json(['error' => '계정과목을 찾을 수 없습니다.'], 404); + } + + // 일반전표 분개 라인 + $journalLines = DB::table('journal_entry_lines as jel') + ->join('journal_entries as je', 'je.id', '=', 'jel.journal_entry_id') + ->leftJoin('trading_partners as tp', function ($join) use ($tenantId) { + $join->on('tp.id', '=', 'jel.trading_partner_id') + ->where('tp.tenant_id', '=', $tenantId); + }) + ->where('jel.tenant_id', $tenantId) + ->where('jel.account_code', $accountCode) + ->whereBetween('je.entry_date', [$startDate, $endDate]) + ->whereNull('je.deleted_at') + ->select([ + 'je.entry_date as date', + 'jel.description', + 'jel.trading_partner_id', + 'jel.trading_partner_name', + 'tp.biz_no', + 'jel.debit_amount', + 'jel.credit_amount', + DB::raw("'journal' as source_type"), + 'jel.journal_entry_id as source_id', + ]); + + // 홈택스 세금계산서 분개 + $hometaxLines = DB::table('hometax_invoice_journals as hij') + ->join('hometax_invoices as hi', 'hi.id', '=', 'hij.hometax_invoice_id') + ->where('hij.tenant_id', $tenantId) + ->where('hij.account_code', $accountCode) + ->whereBetween('hij.write_date', [$startDate, $endDate]) + ->whereNull('hi.deleted_at') + ->select([ + 'hij.write_date as date', + 'hij.description', + DB::raw('NULL as trading_partner_id'), + 'hij.trading_partner_name', + DB::raw("CASE WHEN hi.invoice_type = 'sales' THEN hi.invoicee_corp_num ELSE hi.invoicer_corp_num END as biz_no"), + 'hij.debit_amount', + 'hij.credit_amount', + DB::raw("'hometax' as source_type"), + 'hij.hometax_invoice_id as source_id', + ]); + + // UNION ALL → 날짜순 정렬 + $allLines = $journalLines->unionAll($hometaxLines) + ->orderBy('date') + ->get(); + + // 이월잔액 계산 (조회 시작일 이전) + $carryForward = $this->calculateCarryForward($tenantId, $accountCode, $startDate, $account->category); + + // 월별 그룹핑 + 잔액 계산 + $isDebitNormal = in_array($account->category, ['asset', 'expense']); + $runningBalance = $carryForward['balance']; + $monthlyData = []; + $grandDebit = 0; + $grandCredit = 0; + + foreach ($allLines as $line) { + $month = substr($line->date, 0, 7); // YYYY-MM + + if (! isset($monthlyData[$month])) { + $monthlyData[$month] = [ + 'month' => $month, + 'items' => [], + 'subtotal' => ['debit' => 0, 'credit' => 0], + ]; + } + + $debit = (int) $line->debit_amount; + $credit = (int) $line->credit_amount; + + if ($isDebitNormal) { + $runningBalance += ($debit - $credit); + } else { + $runningBalance += ($credit - $debit); + } + + $monthlyData[$month]['items'][] = [ + 'date' => $line->date, + 'description' => $line->description, + 'trading_partner_name' => $line->trading_partner_name, + 'biz_no' => $line->biz_no, + 'debit_amount' => $debit, + 'credit_amount' => $credit, + 'balance' => $runningBalance, + 'source_type' => $line->source_type, + 'source_id' => $line->source_id, + ]; + + $monthlyData[$month]['subtotal']['debit'] += $debit; + $monthlyData[$month]['subtotal']['credit'] += $credit; + $grandDebit += $debit; + $grandCredit += $credit; + } + + // 누계 계산 + $cumulativeDebit = 0; + $cumulativeCredit = 0; + foreach ($monthlyData as &$md) { + $cumulativeDebit += $md['subtotal']['debit']; + $cumulativeCredit += $md['subtotal']['credit']; + $md['cumulative'] = [ + 'debit' => $cumulativeDebit, + 'credit' => $cumulativeCredit, + ]; + } + unset($md); + + return response()->json([ + 'account' => [ + 'code' => $account->code, + 'name' => $account->name, + 'category' => $account->category, + ], + 'period' => [ + 'start_date' => $startDate, + 'end_date' => $endDate, + ], + 'carry_forward' => $carryForward, + 'monthly_data' => array_values($monthlyData), + 'grand_total' => [ + 'debit' => $grandDebit, + 'credit' => $grandCredit, + 'balance' => $runningBalance, + ], + ]); + } + + /** + * 이월잔액 계산 (조회 시작일 이전 합계) + */ + private function calculateCarryForward(int $tenantId, string $accountCode, string $startDate, string $category): array + { + // 일반전표 + $journalSums = DB::table('journal_entry_lines as jel') + ->join('journal_entries as je', 'je.id', '=', 'jel.journal_entry_id') + ->where('jel.tenant_id', $tenantId) + ->where('jel.account_code', $accountCode) + ->where('je.entry_date', '<', $startDate) + ->whereNull('je.deleted_at') + ->selectRaw('COALESCE(SUM(jel.debit_amount), 0) as total_debit, COALESCE(SUM(jel.credit_amount), 0) as total_credit') + ->first(); + + // 홈택스 + $hometaxSums = DB::table('hometax_invoice_journals as hij') + ->join('hometax_invoices as hi', 'hi.id', '=', 'hij.hometax_invoice_id') + ->where('hij.tenant_id', $tenantId) + ->where('hij.account_code', $accountCode) + ->where('hij.write_date', '<', $startDate) + ->whereNull('hi.deleted_at') + ->selectRaw('COALESCE(SUM(hij.debit_amount), 0) as total_debit, COALESCE(SUM(hij.credit_amount), 0) as total_credit') + ->first(); + + $debit = (int) $journalSums->total_debit + (int) $hometaxSums->total_debit; + $credit = (int) $journalSums->total_credit + (int) $hometaxSums->total_credit; + + $isDebitNormal = in_array($category, ['asset', 'expense']); + $balance = $isDebitNormal ? ($debit - $credit) : ($credit - $debit); + + return compact('debit', 'credit', 'balance'); + } +} diff --git a/app/Http/Controllers/Finance/IncomeStatementController.php b/app/Http/Controllers/Finance/IncomeStatementController.php new file mode 100644 index 00000000..32d8b4f4 --- /dev/null +++ b/app/Http/Controllers/Finance/IncomeStatementController.php @@ -0,0 +1,272 @@ + 'I', 'name' => '매출액', 'type' => 'sum', 'category' => 'revenue', 'sub_category' => 'sales'], + ['code' => 'II', 'name' => '매출원가', 'type' => 'sum', 'category' => 'expense', 'sub_category' => 'cost_of_sales'], + ['code' => 'III', 'name' => '매출총이익', 'type' => 'calc', 'formula' => 'I - II'], + ['code' => 'IV', 'name' => '판매비와관리비', 'type' => 'sum', 'category' => 'expense', 'sub_category' => 'sga'], + ['code' => 'V', 'name' => '영업이익', 'type' => 'calc', 'formula' => 'III - IV'], + ['code' => 'VI', 'name' => '영업외수익', 'type' => 'sum', 'category' => 'revenue', 'sub_category' => 'non_operating'], + ['code' => 'VII', 'name' => '영업외비용', 'type' => 'sum', 'category' => 'expense', 'sub_category' => 'non_operating'], + ['code' => 'VIII', 'name' => '법인세비용차감전순이익', 'type' => 'calc', 'formula' => 'V + VI - VII'], + ['code' => 'IX', 'name' => '법인세비용', 'type' => 'sum', 'category' => 'expense', 'sub_category' => 'tax'], + ['code' => 'X', 'name' => '당기순이익', 'type' => 'calc', 'formula' => 'VIII - IX'], + ]; + + /** + * 손익계산서 페이지 + */ + public function index(Request $request) + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('finance.income-statement')); + } + + return view('finance.income-statement'); + } + + /** + * 손익계산서 데이터 조회 + */ + public function data(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $startDate = $request->input('start_date', now()->startOfYear()->format('Y-m-d')); + $endDate = $request->input('end_date', now()->endOfYear()->format('Y-m-d')); + $unit = $request->input('unit', 'won'); + + // 전기 기간 계산 (전년 동기) + $prevStartDate = date('Y-m-d', strtotime($startDate.' -1 year')); + $prevEndDate = date('Y-m-d', strtotime($endDate.' -1 year')); + + // 당기/전기 계정별 합계 + $currentSums = $this->getAccountSums($tenantId, $startDate, $endDate); + $previousSums = $this->getAccountSums($tenantId, $prevStartDate, $prevEndDate); + + // 계정과목 목록 (sub_category별 세부 항목 표시용) + $accountCodes = AccountCode::where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereIn('category', ['revenue', 'expense']) + ->orderBy('sort_order') + ->orderBy('code') + ->get(); + + // 손익계산서 구조 조립 + $sections = []; + $calcValues = []; + + foreach (self::PL_STRUCTURE as $item) { + $section = [ + 'code' => $item['code'], + 'name' => $item['name'], + 'current_amount' => 0, + 'previous_amount' => 0, + 'items' => [], + 'is_calculated' => $item['type'] === 'calc', + ]; + + if ($item['type'] === 'sum') { + $relatedAccounts = $accountCodes->filter(function ($ac) use ($item) { + return $ac->category === $item['category'] + && $ac->sub_category === ($item['sub_category'] ?? null); + }); + + $currentTotal = 0; + $previousTotal = 0; + + foreach ($relatedAccounts as $ac) { + $curDebit = $currentSums[$ac->code]['debit'] ?? 0; + $curCredit = $currentSums[$ac->code]['credit'] ?? 0; + $prevDebit = $previousSums[$ac->code]['debit'] ?? 0; + $prevCredit = $previousSums[$ac->code]['credit'] ?? 0; + + // 수익: 대변 - 차변, 비용: 차변 - 대변 + if ($ac->category === 'revenue') { + $curAmount = $curCredit - $curDebit; + $prevAmount = $prevCredit - $prevDebit; + } else { + $curAmount = $curDebit - $curCredit; + $prevAmount = $prevDebit - $prevCredit; + } + + if ($curAmount != 0 || $prevAmount != 0) { + $section['items'][] = [ + 'code' => $ac->code, + 'name' => $ac->name, + 'current' => $curAmount, + 'previous' => $prevAmount, + ]; + } + + $currentTotal += $curAmount; + $previousTotal += $prevAmount; + } + + $section['current_amount'] = $currentTotal; + $section['previous_amount'] = $previousTotal; + $calcValues[$item['code']] = ['current' => $currentTotal, 'previous' => $previousTotal]; + } elseif ($item['type'] === 'calc') { + $amounts = $this->evaluateFormula($item['formula'], $calcValues); + $section['current_amount'] = $amounts['current']; + $section['previous_amount'] = $amounts['previous']; + $calcValues[$item['code']] = $amounts; + } + + $sections[] = $section; + } + + // 단위 변환 + $divisor = match ($unit) { + 'thousand' => 1000, + 'million' => 1000000, + default => 1, + }; + + if ($divisor > 1) { + $sections = $this->applyUnitDivisor($sections, $divisor); + } + + // 기수 계산 + $currentYear = (int) date('Y', strtotime($endDate)); + $fiscalYear = $this->getFiscalYear($tenantId, $currentYear); + + return response()->json([ + 'period' => [ + 'current' => [ + 'start' => $startDate, + 'end' => $endDate, + 'label' => "제 {$fiscalYear} (당)기", + ], + 'previous' => [ + 'start' => $prevStartDate, + 'end' => $prevEndDate, + 'label' => '제 '.($fiscalYear - 1).' (전)기', + ], + ], + 'unit' => $unit, + 'sections' => $sections, + ]); + } + + /** + * 계정코드별 차변/대변 합계 조회 + */ + private function getAccountSums(int $tenantId, string $startDate, string $endDate): array + { + // 일반전표 + $journalSums = DB::table('journal_entry_lines as jel') + ->join('journal_entries as je', 'je.id', '=', 'jel.journal_entry_id') + ->where('jel.tenant_id', $tenantId) + ->whereBetween('je.entry_date', [$startDate, $endDate]) + ->whereNull('je.deleted_at') + ->groupBy('jel.account_code') + ->select([ + 'jel.account_code', + DB::raw('SUM(jel.debit_amount) as debit'), + DB::raw('SUM(jel.credit_amount) as credit'), + ]) + ->get(); + + // 홈택스 + $hometaxSums = DB::table('hometax_invoice_journals as hij') + ->join('hometax_invoices as hi', 'hi.id', '=', 'hij.hometax_invoice_id') + ->where('hij.tenant_id', $tenantId) + ->whereBetween('hij.write_date', [$startDate, $endDate]) + ->whereNull('hi.deleted_at') + ->groupBy('hij.account_code') + ->select([ + 'hij.account_code', + DB::raw('SUM(hij.debit_amount) as debit'), + DB::raw('SUM(hij.credit_amount) as credit'), + ]) + ->get(); + + $result = []; + foreach ($journalSums as $row) { + $result[$row->account_code] = [ + 'debit' => (int) $row->debit, + 'credit' => (int) $row->credit, + ]; + } + foreach ($hometaxSums as $row) { + if (! isset($result[$row->account_code])) { + $result[$row->account_code] = ['debit' => 0, 'credit' => 0]; + } + $result[$row->account_code]['debit'] += (int) $row->debit; + $result[$row->account_code]['credit'] += (int) $row->credit; + } + + return $result; + } + + /** + * 계산 공식 평가 (I - II, III - IV 등) + */ + private function evaluateFormula(string $formula, array $values): array + { + $current = 0; + $previous = 0; + + $parts = preg_split('/\s*([+-])\s*/', $formula, -1, PREG_SPLIT_DELIM_CAPTURE); + $sign = 1; + + foreach ($parts as $part) { + $part = trim($part); + if ($part === '+') { + $sign = 1; + } elseif ($part === '-') { + $sign = -1; + } elseif (isset($values[$part])) { + $current += $sign * $values[$part]['current']; + $previous += $sign * $values[$part]['previous']; + $sign = 1; + } + } + + return compact('current', 'previous'); + } + + /** + * 금액 단위 변환 + */ + private function applyUnitDivisor(array $sections, int $divisor): array + { + foreach ($sections as &$section) { + $section['current_amount'] = (int) round($section['current_amount'] / $divisor); + $section['previous_amount'] = (int) round($section['previous_amount'] / $divisor); + + if (! empty($section['items'])) { + foreach ($section['items'] as &$item) { + $item['current'] = (int) round($item['current'] / $divisor); + $item['previous'] = (int) round($item['previous'] / $divisor); + } + unset($item); + } + } + unset($section); + + return $sections; + } + + /** + * 기수 계산 (테넌트별 설립연도 기반, 기본 1기 = 2005년) + */ + private function getFiscalYear(int $tenantId, int $currentYear): int + { + // 기본 1기 기준년도 (향후 테넌트 설정에서 가져올 수 있음) + $baseYear = 2005; + + return $currentYear - $baseYear + 1; + } +} diff --git a/resources/views/finance/account-ledger.blade.php b/resources/views/finance/account-ledger.blade.php new file mode 100644 index 00000000..5f1561ee --- /dev/null +++ b/resources/views/finance/account-ledger.blade.php @@ -0,0 +1,307 @@ +@extends('layouts.app') + +@section('title', '계정별원장') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') +@include('partials.react-cdn') + +@verbatim + +@endverbatim +@endpush diff --git a/resources/views/finance/income-statement.blade.php b/resources/views/finance/income-statement.blade.php new file mode 100644 index 00000000..253a67c0 --- /dev/null +++ b/resources/views/finance/income-statement.blade.php @@ -0,0 +1,236 @@ +@extends('layouts.app') + +@section('title', '손익계산서') + +@push('styles') + +@endpush + +@section('content') + +
+@endsection + +@push('scripts') +@include('partials.react-cdn') + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index 2ad4d7ed..d8070f9b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1247,6 +1247,14 @@ Route::delete('/{id}', [\App\Http\Controllers\Finance\JournalEntryController::class, 'destroy'])->name('destroy'); }); + // 계정별원장 + Route::get('/account-ledger', [\App\Http\Controllers\Finance\AccountLedgerController::class, 'index'])->name('account-ledger'); + Route::get('/account-ledger/list', [\App\Http\Controllers\Finance\AccountLedgerController::class, 'list'])->name('account-ledger.list'); + + // 손익계산서 + Route::get('/income-statement', [\App\Http\Controllers\Finance\IncomeStatementController::class, 'index'])->name('income-statement'); + Route::get('/income-statement/data', [\App\Http\Controllers\Finance\IncomeStatementController::class, 'data'])->name('income-statement.data'); + // 카드관리 Route::get('/corporate-cards', function () { if (request()->header('HX-Request')) {