Files
sam-api/app/Services/IncomeStatementService.php
김보곤 dcf97b468d feat: [finance] 손익계산서 월별 조회 API + 리팩토링
- GET /api/v1/income-statement/monthly?year=2026&unit=won 추가
- buildSections 공통 로직 분리
- getAccountCodes, getFiscalYear 헬퍼 분리
2026-03-19 12:52:40 +09:00

258 lines
10 KiB
PHP

<?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'));
$accountCodes = $this->getAccountCodes($tenantId);
$currentSums = $this->getAccountSums($tenantId, $startDate, $endDate);
$previousSums = $this->getAccountSums($tenantId, $prevStartDate, $prevEndDate);
$sections = $this->buildSections($accountCodes, $currentSums, $previousSums, $unit);
$currentYear = (int) date('Y', strtotime($endDate));
$fiscalYear = $this->getFiscalYear($currentYear);
return [
'period' => [
'current' => ['start' => $startDate, 'end' => $endDate, 'label' => "{$fiscalYear} (당)기"],
'previous' => ['start' => $prevStartDate, 'end' => $prevEndDate, 'label' => '제 '.($fiscalYear - 1).' (전)기'],
],
'unit' => $unit,
'sections' => $sections,
];
}
/**
* 손익계산서 월별 조회
*/
public function monthly(array $params): array
{
$tenantId = $this->tenantId();
$year = (int) ($params['year'] ?? now()->year);
$unit = $params['unit'] ?? 'won';
$accountCodes = $this->getAccountCodes($tenantId);
$months = [];
for ($m = 1; $m <= 12; $m++) {
$startDate = sprintf('%04d-%02d-01', $year, $m);
$endDate = date('Y-m-t', strtotime($startDate));
if (strtotime($startDate) > time()) {
break;
}
$sums = $this->getAccountSums($tenantId, $startDate, $endDate);
$sections = $this->buildSections($accountCodes, $sums, $sums, $unit, true);
$months[] = [
'month' => sprintf('%02d', $m),
'label' => $m.'월',
'sections' => $sections,
];
}
$fiscalYear = $this->getFiscalYear($year);
return [
'year' => $year,
'fiscal_year' => $fiscalYear,
'fiscal_label' => "{$fiscalYear}",
'unit' => $unit,
'months' => $months,
];
}
private function getAccountCodes(int $tenantId)
{
return DB::table('account_codes')
->where('tenant_id', $tenantId)
->where('is_active', true)
->whereIn('category', ['revenue', 'expense'])
->orderBy('sort_order')
->orderBy('code')
->get();
}
private function getFiscalYear(int $currentYear): int
{
return $currentYear - 2024; // 코드브릿지엑스 설립 2025-09-13, 1기 = 2025년
}
/**
* 섹션 조립 공통 로직
*/
private function buildSections($accountCodes, array $currentSums, array $previousSums, string $unit, bool $currentOnly = false): array
{
$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;
$curAmount = $ac->category === 'revenue' ? ($curCredit - $curDebit) : ($curDebit - $curCredit);
$prevAmount = 0;
if (! $currentOnly) {
$prevDebit = $previousSums[$ac->code]['debit'] ?? 0;
$prevCredit = $previousSums[$ac->code]['credit'] ?? 0;
$prevAmount = $ac->category === 'revenue' ? ($prevCredit - $prevDebit) : ($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);
}
return $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');
}
}