# Python으로 Money Forward 스타일 재무 PDF 리포트 만들기 - Part 7 ## 손익계산서 테이블 구현 ### 테이블 레이아웃 분석 Money Forward의 손익계산서 테이블 구조: ``` ┌──────────────────┬────────────────────────────────────────────┬────────┐ │ │ 実績 │ 予算v2 │ │ ├──────┬──────┬──────┬──────┬──────┬──────────┤ │ │ │2025/04│2025/05│2025/06│... │2025/10│ 合計 │ ├──────────────────┼──────┼──────┼──────┼──────┼──────┼──────────┼────────┤ │売上高 │628,802│1,708,772│... │ │ │125,947,574│ │ │ 売上高 │628,802│... │ │ │ │ │ │ │売上原価 │294,019│... │ │ │ │ │ │ │ 期首棚卸 │ 0│... │ │ │ │ │ │ │ 当期仕入 │294,019│... │ │ │ │ │ │ │ [原]支払手数料│ 0│... │ │ │ │ │ │ │... │ │ │ │ │ │ │ │ └──────────────────┴──────┴──────┴──────┴──────┴──────┴──────────┴────────┘ ``` ### ReportLab Table 기초 ```python from reportlab.platypus import Table, TableStyle from reportlab.lib import colors # 기본 테이블 생성 data = [ ['A1', 'B1', 'C1'], ['A2', 'B2', 'C2'], ] table = Table(data, colWidths=[100, 100, 100]) # 스타일 적용 table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.grey), # 헤더 배경 ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), # 헤더 텍스트 ('GRID', (0, 0), (-1, -1), 0.5, colors.black), # 테두리 ])) ``` ### 재무 테이블 컴포넌트 `src/pdf/components/financial_table.py`: ```python """재무 테이블 컴포넌트""" from reportlab.platypus import Table, TableStyle from reportlab.lib import colors from reportlab.pdfgen.canvas import Canvas from typing import Optional from decimal import Decimal from ..styles import COLORS, FONT_GOTHIC, FONT_MINCHO, TABLE_STYLE class FinancialTableRenderer: """재무 테이블 렌더러""" def __init__(self, canvas: Canvas): self.canvas = canvas def format_number(self, value, show_zero: bool = True) -> str: """숫자 포맷팅""" if value is None: return "" if isinstance(value, str): return value if value == 0 and not show_zero: return "" if isinstance(value, float) and '%' in str(value): return f"{value:.2f} %" return f"{int(value):,}" def create_pl_table( self, headers: list[str], data: list[list], col_widths: Optional[list[float]] = None, ) -> Table: """손익계산서 테이블 생성""" # 기본 열 너비 if col_widths is None: item_col = 120 month_col = 58 total_col = 70 col_widths = [item_col] + [month_col] * (len(headers) - 2) + [total_col] # 데이터 포맷팅 formatted_data = [headers] for row in data: formatted_row = [] for i, cell in enumerate(row): if i == 0: # 항목명 formatted_row.append(str(cell)) else: # 숫자 formatted_row.append(self.format_number(cell)) formatted_data.append(formatted_row) # 테이블 생성 table = Table(formatted_data, colWidths=col_widths) # 스타일 정의 style_commands = [ # 전체 폰트 ('FONTNAME', (0, 0), (-1, -1), FONT_MINCHO), ('FONTSIZE', (0, 0), (-1, -1), TABLE_STYLE['body_size']), # 헤더 스타일 ('FONTNAME', (0, 0), (-1, 0), TABLE_STYLE['header_font']), ('FONTSIZE', (0, 0), (-1, 0), TABLE_STYLE['header_size']), ('BACKGROUND', (0, 0), (-1, 0), TABLE_STYLE['header_background']), ('TEXTCOLOR', (0, 0), (-1, 0), TABLE_STYLE['header_text']), ('ALIGN', (0, 0), (-1, 0), 'CENTER'), # 항목 열 (좌측 정렬) ('ALIGN', (0, 1), (0, -1), 'LEFT'), # 숫자 열 (우측 정렬) ('ALIGN', (1, 1), (-1, -1), 'RIGHT'), # 테두리 ('GRID', (0, 0), (-1, -1), 0.5, TABLE_STYLE['border_color']), # 행 높이 ('TOPPADDING', (0, 0), (-1, -1), 3), ('BOTTOMPADDING', (0, 0), (-1, -1), 3), ] # 교차 행 색상 for i in range(2, len(formatted_data), 2): style_commands.append( ('BACKGROUND', (0, i), (-1, i), TABLE_STYLE['alt_row_color']) ) # 대항목 볼드 처리 (들여쓰기 없는 행) for i, row in enumerate(data): if row and not str(row[0]).startswith(' '): style_commands.append( ('FONTNAME', (0, i + 1), (0, i + 1), FONT_GOTHIC) ) table.setStyle(TableStyle(style_commands)) return table def create_comparison_table( self, headers: list[str], data: list[list], budget_cols: list[int], actual_cols: list[int], variance_cols: list[int], ) -> Table: """예산 vs 실적 비교 테이블""" # 포맷팅 formatted_data = [headers] for row in data: formatted_row = [] for i, cell in enumerate(row): if i == 0: formatted_row.append(str(cell)) else: formatted_row.append(self.format_number(cell)) formatted_data.append(formatted_row) table = Table(formatted_data) style_commands = [ ('FONTNAME', (0, 0), (-1, -1), FONT_MINCHO), ('FONTSIZE', (0, 0), (-1, -1), 8), ('FONTNAME', (0, 0), (-1, 0), FONT_GOTHIC), ('BACKGROUND', (0, 0), (-1, 0), TABLE_STYLE['header_background']), ('TEXTCOLOR', (0, 0), (-1, 0), TABLE_STYLE['header_text']), ('ALIGN', (1, 1), (-1, -1), 'RIGHT'), ('GRID', (0, 0), (-1, -1), 0.5, TABLE_STYLE['border_color']), ] # 차이 열 색상 (양수=녹색, 음수=빨강) for row_idx, row in enumerate(data): for col_idx in variance_cols: if col_idx < len(row): try: value = float(row[col_idx]) if row[col_idx] else 0 if value > 0: style_commands.append( ('TEXTCOLOR', (col_idx, row_idx + 1), (col_idx, row_idx + 1), colors.HexColor('#375623')) ) elif value < 0: style_commands.append( ('TEXTCOLOR', (col_idx, row_idx + 1), (col_idx, row_idx + 1), colors.HexColor('#C00000')) ) except (ValueError, TypeError): pass table.setStyle(TableStyle(style_commands)) return table def draw_table( self, table: Table, x: float, y: float, width: Optional[float] = None, height: Optional[float] = None, ): """테이블을 캔버스에 그리기""" table_width, table_height = table.wrap(width or 0, height or 0) table.drawOn(self.canvas, x, y - table_height) return table_height ``` ### 손익계산서 페이지 `src/pdf/pages/pl_table.py`: ```python """손익계산서 페이지""" from reportlab.pdfgen.canvas import Canvas from ..styles import COLORS, FONT_GOTHIC, PAGE_STYLE from ..components.financial_table import FinancialTableRenderer from data_models import FinancialReport class PLTablePage: """損益計算書 페이지""" def __init__(self, canvas: Canvas, report: FinancialReport): self.canvas = canvas self.report = report self.table_renderer = FinancialTableRenderer(canvas) def render(self, page_width: float, page_height: float, page_num: int = 1): """페이지 렌더링""" c = self.canvas margin = PAGE_STYLE['margin_left'] top = page_height - PAGE_STYLE['margin_top'] # 서브헤더 (실적/예산 구분) self._draw_sub_header(margin, top - 60, page_width) # P/L 테이블 self._draw_pl_table(margin, top - 85, page_width, page_num) def _draw_sub_header(self, x: float, y: float, page_width: float): """서브헤더 (실적/예산v2 라벨)""" c = self.canvas # 실적 영역 c.setFont(FONT_GOTHIC, 10) c.setFillColor(COLORS['text']) # 실적 범위 표시 actual_start = x + 130 actual_end = x + 550 c.drawCentredString((actual_start + actual_end) / 2, y, "実績") # 언더라인 c.setStrokeColor(COLORS['border']) c.line(actual_start, y - 3, actual_end, y - 3) # 예산v2 영역 budget_x = x + 560 c.drawString(budget_x, y, "予算v2") def _draw_pl_table(self, x: float, y: float, page_width: float, page_num: int): """P/L 테이블 그리기""" # 월 헤더 months = ['2025/04月', '2025/05月', '2025/06月', '2025/07月', '2025/08月', '2025/09月', '2025/10月', '2025/11月', '2025/12月', '2026/01月', '2026/02月', '2026/03月'] headers = [''] + months + ['合計'] # 샘플 데이터 (실제로는 report에서 가져옴) if page_num == 1: # 1페이지: 매출~판관비 중간 data = [ ['売上高', 628802, 1708772, 5300000, 3380000, 550000, 28350000, 6100000, 16800000, 7600000, 17710000, 18910000, 18910000, 125947574], [' 売上高', 628802, 1708772, 5300000, 3380000, 550000, 28350000, 6100000, 16800000, 7600000, 17710000, 18910000, 18910000, 125947574], ['売上原価', 294019, 86757, 472653, 467652, 382345, 432735, 1201588, 10330000, 4890000, 10956000, 11796000, 11796000, 53105749], [' 期首棚卸', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [' 当期仕入', 294019, 86757, 472653, 467652, 382345, 432735, 1201588, 10330000, 4890000, 10956000, 11796000, 11796000, 53105749], [' [原]支払手数料', 0, 58938, 13015, 3941, 4025, 4043, 4019, 0, 0, 0, 0, 0, 87981], [' [原]外注費', 294019, 27819, 459638, 463711, 378320, 428692, 1197569, 10330000, 4890000, 10956000, 11796000, 11796000, 53017768], ['売上総利益', 334783, 1622015, 4827347, 2912348, 167655, 27917265, 4898412, 6470000, 2710000, 6754000, 7114000, 7114000, 72841825], ['売上総利益率', '53.24 %', '94.92 %', '91.08 %', '86.16 %', '30.48 %', '98.47 %', '80.30 %', '38.51 %', '35.66 %', '38.14 %', '37.62 %', '37.62 %', '57.84 %'], ['販売費及び一般管理費', 1263348, 882505, 1444648, 1752893, 1318232, 1542930, 2763976, 2233792, 2133792, 2133792, 2133792, 2133792, 21737492], [' 人件費', 572325, 572325, 572325, 573413, 572325, 572325, 572325, 600000, 600000, 600000, 600000, 600000, 7007363], [' 役員報酬', 500000, 500000, 500000, 500000, 500000, 500000, 500000, 500000, 500000, 500000, 500000, 500000, 6000000], [' 法定福利費', 72325, 72325, 72325, 72325, 72325, 72325, 72325, 100000, 100000, 100000, 100000, 100000, 1006275], # ... (계속) ] else: # 2페이지: 판관비 계속~당기순이익 data = [ [' 業務委託料', 272728, 272728, 272728, 272728, 318621, 272728, 1301422, 700000, 700000, 700000, 700000, 700000, 6483683], [' 接待交際費', 0, 0, 98649, 0, 26200, 0, 0, 150000, 150000, 150000, 150000, 150000, 874849], [' 旅費交通費', 0, 0, 32276, 118090, 60926, 460778, 139212, 200000, 100000, 100000, 100000, 100000, 1411282], [' 通信費', 0, 1441, 6176, 85051, 20899, 29599, 37438, 30000, 30000, 30000, 30000, 30000, 330604], ['営業利益', -928565, 739510, 3382699, 1159455, -1150577, 26374335, 2134436, 4236208, 576208, 4620208, 4980208, 4980208, 51104333], ['営業利益率', '-147.67 %', '43.28 %', '63.82 %', '34.30 %', '-209.20 %', '93.03 %', '34.99 %', '25.22 %', '7.58 %', '26.09 %', '26.34 %', '26.34 %', '40.58 %'], ['営業外収益', 0, 8, 751, 3556, 1875, 2081, 1298, 0, 0, 0, 0, 0, 9569], ['経常利益', -928565, 739518, 3383450, 1163011, -1148702, 26376416, 2135734, 4236208, 576208, 4620208, 4980208, 4980208, 51113902], ['当期純利益', -928565, 739518, 3383450, 1163011, -1148702, 26376416, 2135734, 4236208, 576208, 4620208, 4980208, 4980208, 51113902], ] # 열 너비 계산 col_widths = [110] + [50] * 12 + [65] table = self.table_renderer.create_pl_table(headers, data, col_widths) self.table_renderer.draw_table(table, x, y) ``` ### 테이블 스타일링 팁 ```python # 특정 셀 병합 ('SPAN', (0, 0), (2, 0)), # 첫 행의 0~2열 병합 # 특정 행 볼드 ('FONTNAME', (0, 5), (-1, 5), FONT_GOTHIC), # 음수 빨간색 def style_negative_values(data, style_commands): for row_idx, row in enumerate(data): for col_idx, cell in enumerate(row): if isinstance(cell, (int, float)) and cell < 0: style_commands.append( ('TEXTCOLOR', (col_idx, row_idx + 1), (col_idx, row_idx + 1), colors.HexColor('#C00000')) ) # 조건부 배경색 if '利益' in item_name: style_commands.append( ('BACKGROUND', (0, row_idx), (-1, row_idx), colors.HexColor('#E2EFDA')) ) ``` ### 다음 단계 Part 8에서는 matplotlib을 사용하여 막대 그래프와 라인 차트를 생성합니다. --- **시리즈 네비게이션** - [x] Part 1: 프로젝트 소개 - [x] Part 2: 환경 설정 - [x] Part 3: Sheets API 연동 - [x] Part 4: 데이터 모델 - [x] Part 5: PDF 기본 레이아웃 - [x] Part 6: KPI 대시보드 - [x] Part 7: 테이블 구현 (현재) - [ ] Part 8: 차트 생성 - [ ] Part 9: 복합 차트 - [ ] Part 10: 완성