# Python으로 Money Forward 스타일 재무 PDF 리포트 만들기 - Part 4 ## 데이터 모델 설계 ### 개요 Google Sheets에서 가져온 원시 데이터를 구조화된 Python 객체로 변환합니다. `dataclass`를 사용하여 타입 안전성과 가독성을 확보합니다. ### 왜 데이터 모델이 필요한가? | 원시 데이터 | 구조화된 데이터 | |-------------|----------------| | `['売上高', '628802', '1708772', ...]` | `PLItem(name='売上高', values=[628802, 1708772, ...])` | | 인덱스로 접근 | 속성으로 접근 | | 타입 불명확 | 타입 힌트 | | 유효성 검증 없음 | 자동 검증 | ### 기본 데이터 모델 `src/data_models.py`: ```python """데이터 모델 정의""" from dataclasses import dataclass, field from typing import Optional from decimal import Decimal from datetime import date @dataclass class MonthlyValue: """월별 값""" month: str # '2025/04月' 형식 budget: Decimal = Decimal(0) actual: Decimal = Decimal(0) @property def variance(self) -> Decimal: """차이 (실적 - 예산)""" return self.actual - self.budget @property def achievement_rate(self) -> Optional[float]: """달성률 (%)""" if self.budget == 0: return None return float(self.actual / self.budget * 100) @dataclass class PLItem: """손익계산서 항목""" name: str level: int = 0 # 들여쓰기 레벨 (0=대항목, 1=중항목, 2=소항목) monthly_values: list[MonthlyValue] = field(default_factory=list) @property def total_budget(self) -> Decimal: """예산 합계""" return sum(v.budget for v in self.monthly_values) @property def total_actual(self) -> Decimal: """실적 합계""" return sum(v.actual for v in self.monthly_values) @property def total_variance(self) -> Decimal: """차이 합계""" return self.total_actual - self.total_budget @dataclass class KPICard: """KPI 카드 데이터""" title: str value: Decimal unit: str = "円" subtitle: str = "" comparison_value: Optional[Decimal] = None comparison_label: str = "" @property def formatted_value(self) -> str: """포맷된 값""" return f"{int(self.value):,}{self.unit}" @property def variance(self) -> Optional[Decimal]: """비교값과의 차이""" if self.comparison_value is None: return None return self.value - self.comparison_value @property def variance_str(self) -> str: """차이 문자열""" if self.variance is None: return "" sign = "+" if self.variance >= 0 else "" return f"{sign}{int(self.variance):,}{self.unit}" @dataclass class ChartData: """차트 데이터""" title: str labels: list[str] # X축 레이블 datasets: list[dict] = field(default_factory=list) # dataset 구조: {'label': '売上高', 'data': [1000, 2000, ...], 'color': '#FF0000'} @dataclass class FinancialReport: """재무 리포트 전체 데이터""" company_name: str fiscal_year: str period: str target_month: str generated_at: date # KPI 섹션 annual_kpis: list[KPICard] = field(default_factory=list) monthly_kpis: list[KPICard] = field(default_factory=list) # P/L 섹션 pl_items: list[PLItem] = field(default_factory=list) # 분석 섹션 budget_vs_actual: list[PLItem] = field(default_factory=list) # 캐시플로우 cashflow_items: list[PLItem] = field(default_factory=list) # 차트 데이터 charts: dict[str, ChartData] = field(default_factory=dict) ``` ### 데이터 파서 구현 `src/data_parser.py`: ```python """원시 데이터 → 데이터 모델 변환""" from decimal import Decimal, InvalidOperation from typing import Optional from data_models import PLItem, MonthlyValue, KPICard, FinancialReport, ChartData from datetime import date def parse_decimal(value: str) -> Decimal: """문자열을 Decimal로 변환""" if not value or value == '-': return Decimal(0) try: # 콤마 제거 후 변환 cleaned = str(value).replace(',', '').replace('円', '').strip() return Decimal(cleaned) except (InvalidOperation, ValueError): return Decimal(0) def parse_percentage(value: str) -> float: """문자열을 퍼센트로 변환""" if not value or value == '-': return 0.0 try: cleaned = str(value).replace('%', '').strip() return float(cleaned) except ValueError: return 0.0 def determine_level(item_name: str) -> int: """항목 이름으로 들여쓰기 레벨 결정""" # 대항목 (레벨 0) major_items = {'売上高', '売上原価', '売上総利益', '販売費及び一般管理費', '営業利益', '営業外収益', '営業外費用', '経常利益', '特別利益', '特別損失', '税引前当期純利益', '当期純利益'} # 공백으로 시작하면 하위 항목 if item_name.startswith(' '): return 2 if item_name.startswith(' ') or item_name.startswith(' '): return 1 if item_name.strip() in major_items: return 0 return 1 def parse_pl_row( row: list, months: list[str], budget_cols: list[int], actual_cols: list[int] ) -> Optional[PLItem]: """P/L 행 파싱""" if not row or len(row) < 2: return None item_name = str(row[1]).strip() if len(row) > 1 else "" if not item_name: return None monthly_values = [] for i, month in enumerate(months): budget = parse_decimal(row[budget_cols[i]]) if budget_cols[i] < len(row) else Decimal(0) actual = parse_decimal(row[actual_cols[i]]) if actual_cols[i] < len(row) else Decimal(0) monthly_values.append(MonthlyValue( month=month, budget=budget, actual=actual )) return PLItem( name=item_name, level=determine_level(item_name), monthly_values=monthly_values ) def parse_comparison_sheet(data: list[list]) -> list[PLItem]: """予実出力 시트 파싱""" if not data or len(data) < 5: return [] # 헤더 행 찾기 (월 정보가 있는 행) header_row = None header_idx = 0 for i, row in enumerate(data[:10]): if any('2025' in str(cell) for cell in row): header_row = row header_idx = i break if header_row is None: return [] # 월 목록 추출 months = [] budget_cols = [] actual_cols = [] for col_idx, cell in enumerate(header_row): cell_str = str(cell) if '月' in cell_str and '2025' in cell_str: months.append(cell_str) # 예산/실적 열 위치는 시트 구조에 따라 조정 actual_cols.append(col_idx) budget_cols.append(col_idx + 1) # 또는 별도 매핑 # 데이터 행 파싱 pl_items = [] for row in data[header_idx + 1:]: item = parse_pl_row(row, months, budget_cols, actual_cols) if item: pl_items.append(item) return pl_items def create_kpi_cards(pl_items: list[PLItem], target_month: str) -> tuple[list[KPICard], list[KPICard]]: """P/L 데이터에서 KPI 카드 생성""" # 주요 항목 찾기 item_map = {item.name.strip(): item for item in pl_items} def get_value(name: str, is_annual: bool = True) -> Decimal: item = item_map.get(name) if not item: return Decimal(0) if is_annual: return item.total_actual # 특정 월 값 for mv in item.monthly_values: if target_month in mv.month: return mv.actual return Decimal(0) # 연간 KPI annual_kpis = [ KPICard( title="売上高", value=get_value("売上高"), subtitle="着地見込", ), KPICard( title="売上総利益", value=get_value("売上総利益"), subtitle="着地見込", ), KPICard( title="営業利益", value=get_value("営業利益"), subtitle="着地見込", ), KPICard( title="当期純利益", value=get_value("当期純利益"), subtitle="着地見込", ), ] # 월간 KPI monthly_kpis = [ KPICard( title="売上高", value=get_value("売上高", False), subtitle=target_month, ), KPICard( title="売上総利益", value=get_value("売上総利益", False), subtitle=target_month, ), KPICard( title="営業利益", value=get_value("営業利益", False), subtitle=target_month, ), KPICard( title="当期純利益", value=get_value("当期純利益", False), subtitle=target_month, ), ] return annual_kpis, monthly_kpis def create_chart_data(pl_items: list[PLItem]) -> dict[str, ChartData]: """차트 데이터 생성""" charts = {} # 월 레이블 추출 if pl_items and pl_items[0].monthly_values: labels = [mv.month for mv in pl_items[0].monthly_values] else: labels = [] # 주요 항목 데이터 추출 item_map = {item.name.strip(): item for item in pl_items} def get_monthly_actuals(name: str) -> list[float]: item = item_map.get(name) if not item: return [0] * len(labels) return [float(mv.actual) for mv in item.monthly_values] # 매출/이익 추이 차트 charts['revenue_profit'] = ChartData( title="売上・利益推移", labels=labels, datasets=[ {'label': '売上高', 'data': get_monthly_actuals('売上高'), 'color': '#4472C4', 'type': 'bar'}, {'label': '売上総利益', 'data': get_monthly_actuals('売上総利益'), 'color': '#70AD47', 'type': 'bar'}, {'label': '営業利益', 'data': get_monthly_actuals('営業利益'), 'color': '#1F4E79', 'type': 'bar'}, {'label': '当期純利益', 'data': get_monthly_actuals('当期純利益'), 'color': '#2E75B6', 'type': 'bar'}, ] ) return charts def build_financial_report( raw_data: list[list], company_name: str = "株式会社GemEgg", fiscal_year: str = "2025年度", period: str = "4月1日〜3月31日", target_month: str = "2025年10月" ) -> FinancialReport: """전체 재무 리포트 데이터 생성""" pl_items = parse_comparison_sheet(raw_data) annual_kpis, monthly_kpis = create_kpi_cards(pl_items, target_month) charts = create_chart_data(pl_items) return FinancialReport( company_name=company_name, fiscal_year=fiscal_year, period=period, target_month=target_month, generated_at=date.today(), annual_kpis=annual_kpis, monthly_kpis=monthly_kpis, pl_items=pl_items, charts=charts, ) ``` ### 사용 예시 ```python from data_fetcher import fetch_comparison_data from data_parser import build_financial_report # 데이터 조회 raw_data = fetch_comparison_data() # 리포트 생성 report = build_financial_report(raw_data) # 데이터 활용 print(f"회사: {report.company_name}") print(f"기간: {report.fiscal_year} ({report.period})") print("\n[연간 KPI]") for kpi in report.annual_kpis: print(f" {kpi.title}: {kpi.formatted_value}") print("\n[P/L 항목]") for item in report.pl_items[:5]: indent = " " * item.level print(f"{indent}{item.name}: {item.total_actual:,}円") ``` ### 다음 단계 Part 5에서는 ReportLab을 사용하여 PDF의 기본 레이아웃을 구현합니다. --- **시리즈 네비게이션** - [x] Part 1: 프로젝트 소개 - [x] Part 2: 환경 설정 - [x] Part 3: Sheets API 연동 - [x] Part 4: 데이터 모델 (현재) - [ ] Part 5: PDF 기본 레이아웃 - [ ] Part 6: KPI 대시보드 - [ ] Part 7: 테이블 구현 - [ ] Part 8: 차트 생성 - [ ] Part 9: 복합 차트 - [ ] Part 10: 완성