diff --git a/app/Http/Controllers/Api/V1/AccountLedgerController.php b/app/Http/Controllers/Api/V1/AccountLedgerController.php new file mode 100644 index 00000000..d51b58cc --- /dev/null +++ b/app/Http/Controllers/Api/V1/AccountLedgerController.php @@ -0,0 +1,31 @@ +validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'account_code' => 'required|string|max:10', + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->index($request->only(['start_date', 'end_date', 'account_code'])); + }, __('message.fetched')); + } +} diff --git a/app/Http/Controllers/Api/V1/IncomeStatementController.php b/app/Http/Controllers/Api/V1/IncomeStatementController.php new file mode 100644 index 00000000..2e13197e --- /dev/null +++ b/app/Http/Controllers/Api/V1/IncomeStatementController.php @@ -0,0 +1,31 @@ +validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'unit' => 'nullable|in:won,thousand,million', + ]); + + return ApiResponse::handle(function () use ($request) { + return $this->service->data($request->only(['start_date', 'end_date', 'unit'])); + }, __('message.fetched')); + } +} diff --git a/app/Services/AccountLedgerService.php b/app/Services/AccountLedgerService.php new file mode 100644 index 00000000..914b92fc --- /dev/null +++ b/app/Services/AccountLedgerService.php @@ -0,0 +1,174 @@ +tenantId(); + $startDate = $params['start_date']; + $endDate = $params['end_date']; + $accountCode = $params['account_code']; + + // 계정과목 정보 + $account = DB::table('account_codes') + ->where('tenant_id', $tenantId) + ->where('code', $accountCode) + ->where('is_active', true) + ->first(['code', 'name', 'category', 'sub_category']); + + if (! $account) { + return [ + 'account' => null, + 'period' => compact('startDate', 'endDate'), + 'carry_forward' => ['debit' => 0, 'credit' => 0, 'balance' => 0], + 'monthly_data' => [], + 'grand_total' => ['debit' => 0, 'credit' => 0, 'balance' => 0], + ]; + } + + // 일반전표 분개 라인 + $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_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', + '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', + ]); + + $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); + + if (! isset($monthlyData[$month])) { + $monthlyData[$month] = [ + 'month' => $month, + 'items' => [], + 'subtotal' => ['debit' => 0, 'credit' => 0], + ]; + } + + $debit = (int) $line->debit_amount; + $credit = (int) $line->credit_amount; + + $runningBalance += $isDebitNormal ? ($debit - $credit) : ($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' => (int) $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 [ + '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/Services/IncomeStatementService.php b/app/Services/IncomeStatementService.php new file mode 100644 index 00000000..08f2939a --- /dev/null +++ b/app/Services/IncomeStatementService.php @@ -0,0 +1,209 @@ + 'I', 'name' => '매출액', 'type' => 'sum', 'category' => 'revenue', 'sub_categories' => ['sales_revenue']], + ['code' => 'II', 'name' => '매출원가', 'type' => 'sum', 'category' => 'expense', 'sub_categories' => ['cogs', 'construction_cost']], + ['code' => 'III', 'name' => '매출총이익', 'type' => 'calc', 'formula' => 'I - II'], + ['code' => 'IV', 'name' => '판매비와관리비', 'type' => 'sum', 'category' => 'expense', 'sub_categories' => ['selling_admin']], + ['code' => 'V', 'name' => '영업이익', 'type' => 'calc', 'formula' => 'III - IV'], + ['code' => 'VI', 'name' => '영업외수익', 'type' => 'sum', 'category' => 'revenue', 'sub_categories' => ['other_revenue']], + ['code' => 'VII', 'name' => '영업외비용', 'type' => 'sum', 'category' => 'expense', 'sub_categories' => ['other_expense'], 'exclude_codes' => ['99800', '99900']], + ['code' => 'VIII', 'name' => '법인세비용차감전순이익', 'type' => 'calc', 'formula' => 'V + VI - VII'], + ['code' => 'IX', 'name' => '법인세비용', 'type' => 'sum', 'category' => 'expense', 'tax_codes' => ['99800', '99900']], + ['code' => 'X', 'name' => '당기순이익', 'type' => 'calc', 'formula' => 'VIII - IX'], + ]; + + /** + * 손익계산서 조회 + */ + public function data(array $params): array + { + $tenantId = $this->tenantId(); + $startDate = $params['start_date']; + $endDate = $params['end_date']; + $unit = $params['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); + + $accountCodes = DB::table('account_codes') + ->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) { + if (! empty($item['tax_codes'])) { + return in_array($ac->code, $item['tax_codes']); + } + $subCategories = $item['sub_categories'] ?? []; + if ($ac->category !== $item['category'] || ! in_array($ac->sub_category, $subCategories)) { + return false; + } + if (! empty($item['exclude_codes']) && in_array($ac->code, $item['exclude_codes'])) { + return false; + } + + return true; + }); + + $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) { + foreach ($sections as &$s) { + $s['current_amount'] = (int) round($s['current_amount'] / $divisor); + $s['previous_amount'] = (int) round($s['previous_amount'] / $divisor); + foreach ($s['items'] as &$it) { + $it['current'] = (int) round($it['current'] / $divisor); + $it['previous'] = (int) round($it['previous'] / $divisor); + } + unset($it); + } + unset($s); + } + + $currentYear = (int) date('Y', strtotime($endDate)); + $baseYear = 2005; + $fiscalYear = $currentYear - $baseYear + 1; + + return [ + '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; + } + + 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'); + } +} diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 0a18dc4e..aa21bd4f 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -12,6 +12,7 @@ * - 대시보드/보고서 */ +use App\Http\Controllers\Api\V1\AccountLedgerController; use App\Http\Controllers\Api\V1\AccountSubjectController; use App\Http\Controllers\Api\V1\BadDebtController; use App\Http\Controllers\Api\V1\BankAccountController; @@ -31,6 +32,7 @@ use App\Http\Controllers\Api\V1\ExpectedExpenseController; use App\Http\Controllers\Api\V1\GeneralJournalEntryController; use App\Http\Controllers\Api\V1\HometaxInvoiceController; +use App\Http\Controllers\Api\V1\IncomeStatementController; use App\Http\Controllers\Api\V1\LoanController; use App\Http\Controllers\Api\V1\PaymentController; use App\Http\Controllers\Api\V1\PayrollController; @@ -407,3 +409,9 @@ Route::delete('/{id}', [BillController::class, 'destroy'])->whereNumber('id')->name('v1.bills.destroy'); Route::patch('/{id}/status', [BillController::class, 'updateStatus'])->whereNumber('id')->name('v1.bills.update-status'); }); + +// Account Ledger API (계정별원장) +Route::get('/account-ledger', [AccountLedgerController::class, 'index'])->name('v1.account-ledger.index'); + +// Income Statement API (손익계산서) +Route::get('/income-statement', [IncomeStatementController::class, 'index'])->name('v1.income-statement.index');