From 53d446b28cb6f3c38384282eb051681a4e6d74c6 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 12:32:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[finance]=20=EC=86=90=EC=9D=B5=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=EA=B8=B0=EC=88=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?+=20=EB=8B=B9=EA=B8=B0/=EC=A0=84=EA=B8=B0=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=20+=20=EC=9B=94=EB=B3=84=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기수: 코드브릿지엑스 설립 2025년 기준 (1기=2025, 2기=2026) - 당기만/당기+전기 토글 버튼 - 월별 보기 모드 (전체/개별 월 선택) - 월별 전체: 가로 스크롤 비교 테이블 - buildSections 공통 로직 분리 --- .../Finance/IncomeStatementController.php | 238 ++++++---- .../views/finance/income-statement.blade.php | 418 +++++++++++++----- routes/web.php | 1 + 3 files changed, 446 insertions(+), 211 deletions(-) diff --git a/app/Http/Controllers/Finance/IncomeStatementController.php b/app/Http/Controllers/Finance/IncomeStatementController.php index 50f89316..50bae3a3 100644 --- a/app/Http/Controllers/Finance/IncomeStatementController.php +++ b/app/Http/Controllers/Finance/IncomeStatementController.php @@ -56,7 +56,6 @@ public function data(Request $request): JsonResponse $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']) @@ -64,95 +63,7 @@ public function data(Request $request): JsonResponse ->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) { - // tax_codes: 특정 계정코드만 포함 (법인세/소득세) - if (! empty($item['tax_codes'])) { - return in_array($ac->code, $item['tax_codes']); - } - - // sub_categories 배열로 매칭 - $subCategories = $item['sub_categories'] ?? []; - if ($ac->category !== $item['category'] || ! in_array($ac->sub_category, $subCategories)) { - return false; - } - - // exclude_codes: 특정 계정코드 제외 (영업외비용에서 법인세 제외) - 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) { - $sections = $this->applyUnitDivisor($sections, $divisor); - } + $sections = $this->buildSections($accountCodes, $currentSums, $previousSums, $unit); // 기수 계산 $currentYear = (int) date('Y', strtotime($endDate)); @@ -277,13 +188,152 @@ private function applyUnitDivisor(array $sections, int $divisor): array } /** - * 기수 계산 (테넌트별 설립연도 기반, 기본 1기 = 2005년) + * 기수 계산 (코드브릿지엑스 설립: 2025-09-13, 1기 = 2025년) */ private function getFiscalYear(int $tenantId, int $currentYear): int { - // 기본 1기 기준년도 (향후 테넌트 설정에서 가져올 수 있음) - $baseYear = 2005; + $baseYear = 2024; // 2025 - 2024 = 1기 - return $currentYear - $baseYear + 1; + return $currentYear - $baseYear; + } + + /** + * 월별 손익계산서 조회 + */ + public function monthly(Request $request): JsonResponse + { + $tenantId = session('selected_tenant_id', 1); + $year = (int) $request->input('year', now()->year); + $unit = $request->input('unit', 'won'); + + $accountCodes = AccountCode::where('tenant_id', $tenantId) + ->where('is_active', true) + ->whereIn('category', ['revenue', 'expense']) + ->orderBy('sort_order') + ->orderBy('code') + ->get(); + + $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($tenantId, $year); + + return response()->json([ + 'year' => $year, + 'fiscal_year' => $fiscalYear, + 'fiscal_label' => "제 {$fiscalYear} 기", + 'unit' => $unit, + 'months' => $months, + ]); + } + + /** + * 섹션 조립 공통 로직 + */ + 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; + + if ($ac->category === 'revenue') { + $curAmount = $curCredit - $curDebit; + } else { + $curAmount = $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) { + $sections = $this->applyUnitDivisor($sections, $divisor); + } + + return $sections; } } diff --git a/resources/views/finance/income-statement.blade.php b/resources/views/finance/income-statement.blade.php index 253a67c0..586184a1 100644 --- a/resources/views/finance/income-statement.blade.php +++ b/resources/views/finance/income-statement.blade.php @@ -40,6 +40,7 @@ const Search = createIcon('search'); const BarChart = createIcon('bar-chart-3'); const Printer = createIcon('printer'); +const Calendar = createIcon('calendar'); const fmt = (n) => { if (n === 0 || n === null || n === undefined) return ''; @@ -49,6 +50,192 @@ const UNIT_LABELS = { won: '원', thousand: '천원', million: '백만원' }; +// ============================================================ +// 손익계산서 테이블 (기간 보기) +// ============================================================ +function PeriodTable({ data, showPrev, cellBorder }) { + const sectionCodes = ['III', 'V', 'VIII', 'X']; + + return ( + + + + + + {showPrev && ( + + )} + + + + + + {showPrev && } + {showPrev && } + + + + {data.sections.map((section) => { + const isCalc = section.is_calculated; + const isHighlight = sectionCodes.includes(section.code); + const rowClass = isCalc ? (isHighlight ? 'bg-emerald-50 font-bold' : 'bg-gray-50 font-semibold') : ''; + + return ( + + + + {isCalc ? ( + <> + + + {showPrev && } + {showPrev && } + + ) : ( + <> + + + {showPrev && } + {showPrev && ( + + )} + + )} + + {!isCalc && section.items.map((item, ii) => { + const isLast = ii === section.items.length - 1; + return ( + + + + + {showPrev && } + {showPrev && } + + ); + })} + + ); + })} + +
과 목 + {data.period.current.label} + + {data.period.previous.label} +
금 액금 액
+ {section.code}. + {section.name} + {fmt(section.current_amount)}{fmt(section.previous_amount)} + {section.items.length === 0 ? fmt(section.current_amount) : ''} + + {section.items.length === 0 ? fmt(section.previous_amount) : ''} +
{item.name}{fmt(item.current)}{isLast ? fmt(section.current_amount) : ''}{fmt(item.previous)}{isLast ? fmt(section.previous_amount) : ''}
+ ); +} + +// ============================================================ +// 손익계산서 테이블 (월별 보기) +// ============================================================ +function MonthlyTable({ monthlyData, selectedMonth, cellBorder }) { + const sectionCodes = ['III', 'V', 'VIII', 'X']; + + // 선택된 월 또는 전체 + const months = selectedMonth === 'all' + ? monthlyData.months + : monthlyData.months.filter(m => m.month === selectedMonth); + + if (months.length === 0) { + return
해당 월의 데이터가 없습니다.
; + } + + // 단일 월: 간결한 1열 테이블 + if (months.length === 1) { + const sec = months[0].sections; + return ( + + + + + + + + + {sec.map((section) => { + const isCalc = section.is_calculated; + const isHighlight = sectionCodes.includes(section.code); + const rowClass = isCalc ? (isHighlight ? 'bg-emerald-50 font-bold' : 'bg-gray-50 font-semibold') : ''; + return ( + + + + + + + {!isCalc && section.items.map((item, ii) => { + const isLast = ii === section.items.length - 1; + return ( + + + + + + ); + })} + + ); + })} + +
과 목{months[0].label}
+ {section.code}.{section.name} + + {(isCalc || section.items.length === 0) ? fmt(section.current_amount) : ''} +
{item.name}{fmt(item.current)}{isLast ? fmt(section.current_amount) : ''}
+ ); + } + + // 전체 월: 가로 스크롤 월별 비교 (요약 — 계산항목만) + const summaryItems = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']; + return ( +
+ + + + + {months.map(m => ( + + ))} + + + + {summaryItems.map(code => { + const first = months[0].sections.find(s => s.code === code); + if (!first) return null; + const isCalc = first.is_calculated; + const isHighlight = sectionCodes.includes(code); + const rowClass = isCalc ? (isHighlight ? 'bg-emerald-50 font-bold' : 'bg-gray-50 font-semibold') : ''; + + return ( + + + {months.map(m => { + const sec = m.sections.find(s => s.code === code); + return ( + + ); + })} + + ); + })} + +
과 목{m.label}
+ {code}.{first.name} + + {fmt(sec?.current_amount)} +
+
+ ); +} + // ============================================================ // 메인 컴포넌트 // ============================================================ @@ -57,23 +244,43 @@ function IncomeStatement() { const [startDate, setStartDate] = useState(thisYear + '-01-01'); const [endDate, setEndDate] = useState(thisYear + '-12-31'); const [unit, setUnit] = useState('won'); + const [showPrev, setShowPrev] = useState(false); + const [viewMode, setViewMode] = useState('period'); // period | monthly + const [selectedMonth, setSelectedMonth] = useState('all'); const [data, setData] = useState(null); + const [monthlyData, setMonthlyData] = useState(null); const [loading, setLoading] = useState(false); - // 자동 조회 useEffect(() => { handleSearch(); }, []); const handleSearch = useCallback(() => { setLoading(true); - const params = new URLSearchParams({ start_date: startDate, end_date: endDate, unit }); - fetch('/finance/income-statement/data?' + params) - .then(r => r.json()) - .then(d => { setData(d); setLoading(false); }) - .catch(() => setLoading(false)); - }, [startDate, endDate, unit]); + if (viewMode === 'period') { + const params = new URLSearchParams({ start_date: startDate, end_date: endDate, unit }); + fetch('/finance/income-statement/data?' + params) + .then(r => r.json()) + .then(d => { setData(d); setLoading(false); }) + .catch(() => setLoading(false)); + } else { + const year = startDate.slice(0, 4); + const params = new URLSearchParams({ year, unit }); + fetch('/finance/income-statement/monthly?' + params) + .then(r => r.json()) + .then(d => { setMonthlyData(d); setLoading(false); }) + .catch(() => setLoading(false)); + } + }, [startDate, endDate, unit, viewMode]); + + const switchViewMode = (mode) => { + setViewMode(mode); + setData(null); + setMonthlyData(null); + }; const cellBorder = 'border border-gray-200'; - const sectionCodes = ['III', 'V', 'VIII', 'X']; + + const btnActive = 'bg-emerald-600 text-white'; + const btnInactive = 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'; return (
@@ -83,27 +290,82 @@ function IncomeStatement() {

손익계산서

- {data && ( - - )} + {/* 조회 조건 */} -
-
-
- -
- setStartDate(e.target.value)} - className="px-2 py-1.5 text-sm border border-gray-300 rounded" /> - ~ - setEndDate(e.target.value)} - className="px-2 py-1.5 text-sm border border-gray-300 rounded" /> -
+
+ {/* 1행: 보기 모드 + 당기/전기 토글 */} +
+
+ +
+ + {viewMode === 'period' && ( +
+ + +
+ )} + + {viewMode === 'monthly' && monthlyData && ( +
+ + {monthlyData.months.map(m => ( + + ))} +
+ )} +
+ + {/* 2행: 기간 + 단위 + 조회 */} +
+ {viewMode === 'period' ? ( +
+ +
+ setStartDate(e.target.value)} + className="px-2 py-1.5 text-sm border border-gray-300 rounded" /> + ~ + setEndDate(e.target.value)} + className="px-2 py-1.5 text-sm border border-gray-300 rounded" /> +
+
+ ) : ( +
+ + +
+ )}