feat: [finance] 계정별원장·손익계산서 API 추가
- GET /api/v1/account-ledger: 계정별원장 조회 (이월잔액, 월별소계/누계) - GET /api/v1/income-statement: 손익계산서 조회 (당기/전기 비교, 단위변환) - KIS 표준 계정과목 sub_category 기반 자동 매핑 - 일반전표 + 홈택스 분개 UNION ALL 통합 집계
This commit is contained in:
31
app/Http/Controllers/Api/V1/AccountLedgerController.php
Normal file
31
app/Http/Controllers/Api/V1/AccountLedgerController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AccountLedgerService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountLedgerController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountLedgerService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정별원장 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$request->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'));
|
||||
}
|
||||
}
|
||||
31
app/Http/Controllers/Api/V1/IncomeStatementController.php
Normal file
31
app/Http/Controllers/Api/V1/IncomeStatementController.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\IncomeStatementService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class IncomeStatementController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IncomeStatementService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 손익계산서 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$request->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'));
|
||||
}
|
||||
}
|
||||
174
app/Services/AccountLedgerService.php
Normal file
174
app/Services/AccountLedgerService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AccountLedgerService extends Service
|
||||
{
|
||||
/**
|
||||
* 계정별원장 조회
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->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');
|
||||
}
|
||||
}
|
||||
209
app/Services/IncomeStatementService.php
Normal file
209
app/Services/IncomeStatementService.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IncomeStatementService extends Service
|
||||
{
|
||||
// 손익계산서 항목 구조 (한국 일반기업회계기준, KIS 표준 계정과목 sub_category 매핑)
|
||||
private const PL_STRUCTURE = [
|
||||
['code' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user