"""
資料模型 - Transaction, Account, Category 核心資料結構
實作記帳系統的核心資料模型,包括:
- Transaction: 交易記錄
- Account: 帳戶資訊
- Category: 分類管理
- TransactionType: 交易類型列舉
"""
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime, date
from enum import Enum
from typing import Dict, Any, Optional, List
from uuid import uuid4
import json
class TransactionType(Enum):
"""交易類型列舉"""
INCOME = "income"
EXPENSE = "expense"
@classmethod
def from_amount(cls, amount: Decimal) -> "TransactionType":
"""根據金額判斷交易類型"""
if amount == 0:
raise ValueError("金額不能為零")
return cls.INCOME if amount > 0 else cls.EXPENSE
class Transaction:
"""交易記錄模型"""
def __init__(
self,
amount: Decimal,
category: str,
description: str = "",
date: Optional[date] = None,
id: Optional[str] = None,
timestamp: Optional[datetime] = None
):
# 驗證並設定金額
self.amount = self._validate_amount(amount)
# 驗證並設定分類
self.category = self._validate_category(category)
# 設定描述
self.description = description or ""
# 設定日期(預設為今天)
self.date = date or datetime.now().date()
self._validate_date(self.date)
# 設定 ID(若未提供則產生)
self.id = id or self._generate_id()
# 設定時間戳(若未提供則使用目前時間)
self.timestamp = timestamp or datetime.now()
# 根據金額決定交易類型
self.transaction_type = TransactionType.from_amount(self.amount)
def _validate_amount(self, amount: Decimal) -> Decimal:
"""驗證金額格式與精度"""
if not isinstance(amount, Decimal):
amount = Decimal(str(amount))
# 檢查金額不能為零
if amount == 0:
raise ValueError("金額不能為零")
# 檢查精度(最多 2 位小數)
if amount.as_tuple().exponent < -2:
raise ValueError("金額精度不能超過 2 位小數")
# 四捨五入到 2 位小數
return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
def _validate_category(self, category: str) -> str:
"""驗證分類"""
if not category or not category.strip():
raise ValueError("分類不能為空")
return category.strip()
def _validate_date(self, transaction_date: date) -> None:
"""驗證交易日期"""
if transaction_date > date.today():
raise ValueError("交易日期不能是未來")
def _generate_id(self) -> str:
"""產生唯一的交易 ID"""
return f"txn_{self.date.strftime('%Y%m%d')}_{str(uuid4())[:8]}"
def to_dict(self) -> Dict[str, Any]:
"""轉換為字典格式"""
return {
"id": self.id,
"amount": float(self.amount),
"category": self.category,
"description": self.description,
"date": self.date.isoformat(),
"timestamp": self.timestamp.isoformat() + "Z" if self.timestamp.tzinfo is None else self.timestamp.isoformat(),
"type": self.transaction_type.value
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Transaction":
"""從字典建立交易物件"""
# 解析日期
transaction_date = datetime.fromisoformat(data["date"]).date() if isinstance(data["date"], str) else data["date"]
# 解析時間戳
timestamp = None
if "timestamp" in data and data["timestamp"]:
timestamp_str = data["timestamp"].rstrip("Z")
timestamp = datetime.fromisoformat(timestamp_str)
return cls(
id=data["id"],
amount=Decimal(str(data["amount"])),
category=data["category"],
description=data.get("description", ""),
date=transaction_date,
timestamp=timestamp
)
def __str__(self) -> str:
"""字串表示"""
type_symbol = "+" if self.transaction_type == TransactionType.INCOME else "-"
return f"{type_symbol}NTD {abs(self.amount)} [{self.category}] {self.description}"
def __repr__(self) -> str:
"""除錯字串表示"""
return f"Transaction(id={self.id}, amount={self.amount}, category={self.category})"
class Account:
"""帳戶資訊模型"""
def __init__(
self,
balance: Decimal = Decimal('0.00'),
total_transactions: int = 0,
created_at: Optional[datetime] = None,
last_updated: Optional[datetime] = None
):
self.balance = Decimal(str(balance)) if balance else Decimal('0.00')
self.total_transactions = total_transactions
self.created_at = created_at or datetime.now()
self.last_updated = last_updated or datetime.now()
def add_transaction(self, transaction: Transaction) -> None:
"""新增交易到帳戶"""
self.balance += transaction.amount
self.total_transactions += 1
self.last_updated = datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""轉換為字典格式"""
return {
"balance": float(self.balance),
"total_transactions": self.total_transactions,
"created_at": self.created_at.isoformat() + "Z" if self.created_at.tzinfo is None else self.created_at.isoformat(),
"last_updated": self.last_updated.isoformat() + "Z" if self.last_updated.tzinfo is None else self.last_updated.isoformat()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Account":
"""從字典建立帳戶物件"""
# 解析時間戳
created_at = None
last_updated = None
if "created_at" in data and data["created_at"]:
created_str = data["created_at"].rstrip("Z")
created_at = datetime.fromisoformat(created_str)
if "last_updated" in data and data["last_updated"]:
updated_str = data["last_updated"].rstrip("Z")
last_updated = datetime.fromisoformat(updated_str)
return cls(
balance=Decimal(str(data.get("balance", 0))),
total_transactions=data.get("total_transactions", 0),
created_at=created_at,
last_updated=last_updated
)
def __str__(self) -> str:
"""字串表示"""
return f"Account(餘額: NTD {self.balance}, 交易數: {self.total_transactions})"
class Category:
"""分類模型"""
def __init__(
self,
id: str,
name: str,
description: str = "",
category_type: TransactionType = TransactionType.EXPENSE,
color: str = "#D3D3D3",
icon: str = "📝"
):
# 驗證分類 ID
if not id or not id.strip():
raise ValueError("分類 ID 不能為空")
self.id = id.strip()
self.name = name or self.id
self.description = description
self.category_type = category_type
self.color = color
self.icon = icon
def to_dict(self) -> Dict[str, Any]:
"""轉換為字典格式"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"type": self.category_type.value,
"color": self.color,
"icon": self.icon
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Category":
"""從字典建立分類物件"""
category_type = TransactionType.EXPENSE
if "type" in data:
if data["type"] == "income":
category_type = TransactionType.INCOME
elif data["type"] == "expense":
category_type = TransactionType.EXPENSE
return cls(
id=data["id"],
name=data.get("name", data["id"]),
description=data.get("description", ""),
category_type=category_type,
color=data.get("color", "#D3D3D3"),
icon=data.get("icon", "📝")
)
def __str__(self) -> str:
"""字串表示"""
return f"{self.icon} {self.name} ({self.category_type.value})"
def __repr__(self) -> str:
"""除錯字串表示"""
return f"Category(id={self.id}, name={self.name}, type={self.category_type.value})"
# 輔助函式
def create_default_categories() -> List[Category]:
"""建立預設分類清單"""
return [
Category(
id="food",
name="餐飲",
description="餐費、外賣、聚餐等飲食相關支出",
category_type=TransactionType.EXPENSE,
color="#FF6B6B",
icon="🍽️"
),
Category(
id="transport",
name="交通",
description="公車、地鐵、叫車、加油等交通費用",
category_type=TransactionType.EXPENSE,
color="#4ECDC4",
icon="🚗"
),
Category(
id="entertainment",
name="娛樂",
description="電影、遊戲、旅遊等娛樂消費",
category_type=TransactionType.EXPENSE,
color="#45B7D1",
icon="🎬"
),
Category(
id="shopping",
name="購物",
description="服裝、日用品、電子產品等購物支出",
category_type=TransactionType.EXPENSE,
color="#96CEB4",
icon="🛍️"
),
Category(
id="healthcare",
name="醫療",
description="醫院、藥品、體檢等醫療健康支出",
category_type=TransactionType.EXPENSE,
color="#FFEAA7",
icon="🏥"
),
Category(
id="education",
name="教育",
description="培訓、課程、書籍等教育投資",
category_type=TransactionType.EXPENSE,
color="#DDA0DD",
icon="📚"
),
Category(
id="income",
name="收入",
description="工資、獎金、投資收益等收入",
category_type=TransactionType.INCOME,
color="#90EE90",
icon="💰"
),
Category(
id="other",
name="其他",
description="其他未分類的支出或收入",
category_type=TransactionType.EXPENSE,
color="#D3D3D3",
icon="📝"
)
]