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 (
+
+
+
+ | 과 목 |
+
+ {data.period.current.label}
+ |
+ {showPrev && (
+
+ {data.period.previous.label}
+ |
+ )}
+
+
+ |
+ 금 액 |
+ |
+ {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 (
+
+
+ |
+ {section.code}.
+ {section.name}
+ |
+ {isCalc ? (
+ <>
+ |
+ {fmt(section.current_amount)} |
+ {showPrev && | }
+ {showPrev && {fmt(section.previous_amount)} | }
+ >
+ ) : (
+ <>
+ |
+
+ {section.items.length === 0 ? fmt(section.current_amount) : ''}
+ |
+ {showPrev && | }
+ {showPrev && (
+
+ {section.items.length === 0 ? fmt(section.previous_amount) : ''}
+ |
+ )}
+ >
+ )}
+
+ {!isCalc && section.items.map((item, ii) => {
+ const isLast = ii === section.items.length - 1;
+ return (
+
+ | {item.name} |
+ {fmt(item.current)} |
+ {isLast ? fmt(section.current_amount) : ''} |
+ {showPrev && {fmt(item.previous)} | }
+ {showPrev && {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 (
+
+
+
+ | 과 목 |
+ {months[0].label} |
+
+
+
+ {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 (
+
+
+ |
+ {section.code}.{section.name}
+ |
+ |
+
+ {(isCalc || section.items.length === 0) ? fmt(section.current_amount) : ''}
+ |
+
+ {!isCalc && section.items.map((item, ii) => {
+ const isLast = ii === section.items.length - 1;
+ return (
+
+ | {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 => (
+ {m.label} |
+ ))}
+
+
+
+ {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 (
+
+ |
+ {code}.{first.name}
+ |
+ {months.map(m => {
+ const sec = m.sections.find(s => s.code === code);
+ return (
+
+ {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 && (
-