diff --git a/application/repositories/__init__.py b/application/repositories/__init__.py new file mode 100644 index 0000000..d81c55b --- /dev/null +++ b/application/repositories/__init__.py @@ -0,0 +1,3 @@ +from application.repositories.model_repository import ModelRepository + +__all__ = ["ModelRepository"] diff --git a/application/repositories/model_repository.py b/application/repositories/model_repository.py new file mode 100644 index 0000000..af3d448 --- /dev/null +++ b/application/repositories/model_repository.py @@ -0,0 +1,20 @@ +"""Repository protocol для хранения FinancialModel. + +Определяет контракт: load(path) -> FinancialModel, save(model, path) -> None. +Реализации: JsonFileRepository, ExcelRepository. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Protocol, runtime_checkable + +from domain import FinancialModel + + +@runtime_checkable +class ModelRepository(Protocol): + """Любой storage, способный прочитать и записать FinancialModel.""" + + def load(self, path: str | Path) -> FinancialModel: ... + + def save(self, model: FinancialModel, path: str | Path) -> None: ... diff --git a/domain/model.py b/domain/model.py index ec49339..e242215 100644 --- a/domain/model.py +++ b/domain/model.py @@ -1,6 +1,3 @@ -import json -from pathlib import Path - from pydantic import BaseModel, Field from domain.account import Account @@ -13,7 +10,11 @@ from domain.transaction import Transaction class FinancialModel(BaseModel): - """Корневая модель финансового плана.""" + """Корневая модель финансового плана. + + Сериализация (save/load) вынесена в `application.repositories.ModelRepository` — + JsonFileRepository и ExcelRepository. Сам класс хранит только данные. + """ SCHEMA_VERSION: int = 1 # NB: не Field — это class-level metadata, не pydantic field @@ -68,16 +69,3 @@ class FinancialModel(BaseModel): def _from_legacy(cls, data: dict) -> "FinancialModel": # Legacy: файлы без version. Структура совпадает с v1. return cls._from_v1(data) - - def save(self, path: str | Path) -> None: - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) - - @classmethod - def load(cls, path: str | Path) -> "FinancialModel": - path = Path(path) - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return cls.from_dict(data) diff --git a/infrastructure/cli/config.py b/infrastructure/cli/config.py index 3d9c8de..2cc905d 100644 --- a/infrastructure/cli/config.py +++ b/infrastructure/cli/config.py @@ -14,6 +14,7 @@ from domain import ( Transaction, ) from infrastructure.cli.i18n import t +from infrastructure.repositories import JsonFileRepository app = typer.Typer(name="config", help=t("config.help")) console = Console() @@ -21,15 +22,17 @@ console = Console() DATA_DIR = Path("data") MODEL_PATH = DATA_DIR / "model.json" +_json_repo = JsonFileRepository() + def _load_model() -> FinancialModel: if MODEL_PATH.exists(): - return FinancialModel.load(MODEL_PATH) + return _json_repo.load(MODEL_PATH) return FinancialModel() def _save_model(model: FinancialModel) -> None: - model.save(MODEL_PATH) + _json_repo.save(model, MODEL_PATH) # ── base-currency ────────────────────────────────────────────── diff --git a/infrastructure/cli/main.py b/infrastructure/cli/main.py index bc4ff13..2f075da 100644 --- a/infrastructure/cli/main.py +++ b/infrastructure/cli/main.py @@ -11,7 +11,7 @@ from infrastructure.cli.config import app as config_app from infrastructure.cli.i18n import setup_i18n, t from application.forecast import ForecastService from application.scenarios import DEFAULT_SCENARIOS, ScenarioService -from infrastructure.sync.excel_sync import ExcelSync +from infrastructure.repositories import ExcelRepository, JsonFileRepository try: sys.stdout.reconfigure(encoding="utf-8") @@ -28,15 +28,18 @@ MODEL_PATH = DATA_DIR / "model.json" CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) +_json_repo = JsonFileRepository() +_excel_repo = ExcelRepository() + def _load_model() -> FinancialModel: if MODEL_PATH.exists(): - return FinancialModel.load(MODEL_PATH) + return _json_repo.load(MODEL_PATH) return FinancialModel() def _save_model(model: FinancialModel) -> None: - model.save(MODEL_PATH) + _json_repo.save(model, MODEL_PATH) def _get_converter(model: FinancialModel) -> CurrencyConverter: @@ -205,9 +208,8 @@ def import_xlsx( path: str = typer.Argument(..., help=t("cmd.import.arg.path")), ) -> None: """Импорт данных из Excel""" - sync = ExcelSync() try: - model = sync.import_model(path) + model = _excel_repo.load(path) _save_model(model) console.print(t("cmd.import.ok", accounts=len(model.accounts), @@ -227,9 +229,8 @@ def export_xlsx( ) -> None: """Экспорт модели в Excel""" model = _load_model() - sync = ExcelSync() try: - sync.export_model(model, path) + _excel_repo.save(model, path) console.print(t("cmd.export.ok", path=path)) except Exception as e: console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]") diff --git a/infrastructure/repositories/__init__.py b/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..02721f4 --- /dev/null +++ b/infrastructure/repositories/__init__.py @@ -0,0 +1,4 @@ +from infrastructure.repositories.excel_repository import ExcelRepository, ExcelRepositoryError +from infrastructure.repositories.json_file_repository import JsonFileRepository + +__all__ = ["JsonFileRepository", "ExcelRepository", "ExcelRepositoryError"] diff --git a/infrastructure/sync/excel_sync.py b/infrastructure/repositories/excel_repository.py similarity index 71% rename from infrastructure/sync/excel_sync.py rename to infrastructure/repositories/excel_repository.py index 953dc1f..065c27e 100644 --- a/infrastructure/sync/excel_sync.py +++ b/infrastructure/repositories/excel_repository.py @@ -1,8 +1,10 @@ +"""ExcelRepository — хранит FinancialModel в .xlsx (5 листов + ModelInfo).""" from pathlib import Path from uuid import UUID from openpyxl import Workbook, load_workbook +from application.repositories.model_repository import ModelRepository from domain import ( Account, Asset, @@ -14,7 +16,7 @@ from domain import ( ) -class SyncError(Exception): +class ExcelRepositoryError(Exception): pass @@ -46,13 +48,13 @@ _SHEET_CONFIG = { } -class ExcelSync: - def import_model(self, path: str | Path) -> FinancialModel: - path = Path(path) - if not path.exists(): - raise SyncError(f"File not found: {path}") +class ExcelRepository(ModelRepository): + def load(self, path: str | Path) -> FinancialModel: + p = Path(path) + if not p.exists(): + raise ExcelRepositoryError(f"File not found: {p}") - wb = load_workbook(path, read_only=True, data_only=True) + wb = load_workbook(p, read_only=True, data_only=True) model = FinancialModel() model.exchange_rates = [] @@ -73,7 +75,7 @@ class ExcelSync: val = row[i] if i < len(row) else None if val is not None: data[header] = str(val) if not isinstance(val, (int, float)) else val - self._add_to_model(model, sheet_name, data) + self._add_to_model(model, sheet_name, data, config["cls"]) if "ModelInfo" in wb.sheetnames: ws = wb["ModelInfo"] @@ -85,8 +87,8 @@ class ExcelSync: wb.close() return model - def export_model(self, model: FinancialModel, path: str | Path) -> None: - path = Path(path) + def save(self, model: FinancialModel, path: str | Path) -> None: + p = Path(path) wb = Workbook() wb.remove(wb.active) @@ -115,23 +117,22 @@ class ExcelSync: ws_info.append(["Property", "Value"]) ws_info.append(["base_currency", model.base_currency]) - wb.save(path) + wb.save(p) - def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None: + def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict, cls) -> None: try: - cls = _SHEET_CONFIG[sheet_name]["cls"] instance = cls.model_validate(data) - if sheet_name == "Accounts": - model.accounts.append(instance) - elif sheet_name == "Transactions": - model.transactions.append(instance) - elif sheet_name == "Recurring": - model.recurring.append(instance) - elif sheet_name == "Assets": - model.assets.append(instance) - elif sheet_name == "Liabilities": - model.liabilities.append(instance) - elif sheet_name == "ExchangeRates": - model.exchange_rates.append(instance) except Exception as e: - raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e + raise ExcelRepositoryError(f"Failed to parse row in {sheet_name}: {e}") from e + if sheet_name == "Accounts": + model.accounts.append(instance) + elif sheet_name == "Transactions": + model.transactions.append(instance) + elif sheet_name == "Recurring": + model.recurring.append(instance) + elif sheet_name == "Assets": + model.assets.append(instance) + elif sheet_name == "Liabilities": + model.liabilities.append(instance) + elif sheet_name == "ExchangeRates": + model.exchange_rates.append(instance) diff --git a/infrastructure/repositories/json_file_repository.py b/infrastructure/repositories/json_file_repository.py new file mode 100644 index 0000000..34479ae --- /dev/null +++ b/infrastructure/repositories/json_file_repository.py @@ -0,0 +1,20 @@ +"""JsonFileRepository — хранит FinancialModel в .json (включая version).""" +import json +from pathlib import Path + +from application.repositories.model_repository import ModelRepository +from domain import FinancialModel + + +class JsonFileRepository(ModelRepository): + def load(self, path: str | Path) -> FinancialModel: + p = Path(path) + with open(p, "r", encoding="utf-8") as f: + data = json.load(f) + return FinancialModel.from_dict(data) + + def save(self, model: FinancialModel, path: str | Path) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + json.dump(model.to_dict(), f, indent=2, ensure_ascii=False) diff --git a/infrastructure/sync/__init__.py b/infrastructure/sync/__init__.py deleted file mode 100644 index 0dc93e3..0000000 --- a/infrastructure/sync/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from infrastructure.sync.excel_sync import ExcelSync, SyncError - -__all__ = ["ExcelSync", "SyncError"] diff --git a/tests/test_excel_sync.py b/tests/test_excel_sync.py index 1b71a7f..1a9013b 100644 --- a/tests/test_excel_sync.py +++ b/tests/test_excel_sync.py @@ -1,22 +1,22 @@ from pathlib import Path from domain import FinancialModel -from infrastructure.sync.excel_sync import ExcelSync, SyncError +from infrastructure.repositories import ExcelRepository, ExcelRepositoryError -class TestExcelSync: +class TestExcelRepository: def test_export_creates_file(self, sample_model, tmp_path: Path): - sync = ExcelSync() + repo = ExcelRepository() p = tmp_path / "test.xlsx" - sync.export_model(sample_model, p) + repo.save(sample_model, p) assert p.exists() def test_export_import_roundtrip(self, sample_model, tmp_path: Path): - sync = ExcelSync() + repo = ExcelRepository() p = tmp_path / "roundtrip.xlsx" - sync.export_model(sample_model, p) + repo.save(sample_model, p) - loaded = sync.import_model(p) + loaded = repo.load(p) assert len(loaded.accounts) == len(sample_model.accounts) assert len(loaded.transactions) == len(sample_model.transactions) assert len(loaded.recurring) == len(sample_model.recurring) @@ -25,25 +25,25 @@ class TestExcelSync: assert len(loaded.exchange_rates) == len(sample_model.exchange_rates) def test_roundtrip_preserves_base_currency(self, tmp_path: Path): - sync = ExcelSync() + repo = ExcelRepository() model = FinancialModel(base_currency="EUR") p = tmp_path / "eur_model.xlsx" - sync.export_model(model, p) + repo.save(model, p) - loaded = sync.import_model(p) + loaded = repo.load(p) assert loaded.base_currency == "EUR" def test_import_missing_file(self): - sync = ExcelSync() + repo = ExcelRepository() try: - sync.import_model("nonexistent.xlsx") - assert False, "Expected SyncError" - except SyncError: + repo.load("nonexistent.xlsx") + assert False, "Expected ExcelRepositoryError" + except ExcelRepositoryError: pass def test_export_empty_model(self, tmp_path: Path): - sync = ExcelSync() + repo = ExcelRepository() model = FinancialModel() p = tmp_path / "empty.xlsx" - sync.export_model(model, p) + repo.save(model, p) assert p.exists() diff --git a/tests/test_model.py b/tests/test_model.py index 94880f3..98995ea 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,3 +1,4 @@ +from decimal import Decimal from pathlib import Path from domain import ( @@ -10,17 +11,21 @@ from domain import ( RecurringCashflow, Transaction, ) +from infrastructure.repositories import JsonFileRepository + + +_repo = JsonFileRepository() class TestAccount: def test_create(self): - a = Account(name="Test", balance=100.0) + a = Account(name="Test", balance=Decimal("100.0")) assert a.name == "Test" - assert a.balance == 100.0 + assert a.balance == Decimal("100.0") assert a.currency == "USD" def test_to_dict_roundtrip(self): - a = Account(name="Test", balance=100.0) + a = Account(name="Test", balance=Decimal("100.0")) d = a.model_dump(mode="json") a2 = Account.model_validate(d) assert a2.name == a.name @@ -30,11 +35,11 @@ class TestAccount: class TestTransaction: def test_create(self): - t = Transaction(amount=500.0, category="food") - assert t.amount == 500.0 + t = Transaction(amount=Decimal("500.0"), category="food") + assert t.amount == Decimal("500.0") def test_roundtrip(self): - t = Transaction(amount=-100.0, category="rent", description="test") + t = Transaction(amount=Decimal("-100.0"), category="rent", description="test") d = t.model_dump(mode="json") t2 = Transaction.model_validate(d) assert t2.amount == t.amount @@ -50,8 +55,8 @@ class TestFinancialModel: def test_base_currency_roundtrip(self, tmp_path: Path): model = FinancialModel(base_currency="EUR") p = tmp_path / "model.json" - model.save(p) - loaded = FinancialModel.load(p) + _repo.save(model, p) + loaded = _repo.load(p) assert loaded.base_currency == "EUR" def test_base_currency_backward_compat(self, tmp_path: Path): @@ -59,7 +64,7 @@ class TestFinancialModel: p = tmp_path / "legacy.json" with open(p, "w") as f: json.dump({"accounts": []}, f) - loaded = FinancialModel.load(p) + loaded = _repo.load(p) assert loaded.base_currency == "RUB" def test_exchange_rates_default(self): @@ -67,18 +72,18 @@ class TestFinancialModel: assert len(model.exchange_rates) == 1 assert model.exchange_rates[0].from_currency == "USD" assert model.exchange_rates[0].to_currency == "RUB" - assert model.exchange_rates[0].rate == 80.0 + assert model.exchange_rates[0].rate == Decimal("80") def test_save_load(self, tmp_path: Path): model = FinancialModel() - model.accounts.append(Account(name="Test", balance=100.0)) - model.transactions.append(Transaction(amount=50.0, category="income")) + model.accounts.append(Account(name="Test", balance=Decimal("100"))) + model.transactions.append(Transaction(amount=Decimal("50"), category="income")) p = tmp_path / "model.json" - model.save(p) + _repo.save(model, p) assert p.exists() - loaded = FinancialModel.load(p) + loaded = _repo.load(p) assert len(loaded.accounts) == 1 assert len(loaded.transactions) == 1 assert loaded.accounts[0].name == "Test" @@ -94,16 +99,16 @@ class TestFinancialModel: def test_all_entities_roundtrip(self, tmp_path: Path): model = FinancialModel( accounts=[Account(name="A"), Account(name="B")], - transactions=[Transaction(amount=100.0)], - recurring=[RecurringCashflow(amount=50.0)], - assets=[Asset(name="Stock", value=1000.0)], - liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)], + transactions=[Transaction(amount=Decimal("100"))], + recurring=[RecurringCashflow(amount=Decimal("50"))], + assets=[Asset(name="Stock", value=Decimal("1000"))], + liabilities=[Liability(name="Loan", balance=Decimal("500"), interest=Decimal("5"), payment=Decimal("100"))], scenarios=[ForecastScenario(name="test")], - exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)], + exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=Decimal("80"))], ) p = tmp_path / "full.json" - model.save(p) - loaded = FinancialModel.load(p) + _repo.save(model, p) + loaded = _repo.load(p) assert len(loaded.accounts) == 2 assert len(loaded.transactions) == 1 assert len(loaded.recurring) == 1 diff --git a/tests/test_repositories.py b/tests/test_repositories.py new file mode 100644 index 0000000..fc3f77b --- /dev/null +++ b/tests/test_repositories.py @@ -0,0 +1,56 @@ +from decimal import Decimal +from pathlib import Path + +from domain import Account, Asset, FinancialModel, Transaction +from application.repositories import ModelRepository +from infrastructure.repositories import JsonFileRepository + + +class TestJsonFileRepository: + def test_save_load_roundtrip(self, tmp_path: Path): + repo = JsonFileRepository() + model = FinancialModel( + accounts=[Account(name="Test", balance=Decimal("100.50"))], + transactions=[Transaction(amount=Decimal("33.33"), category="income")], + ) + p = tmp_path / "model.json" + repo.save(model, p) + assert p.exists() + + loaded = repo.load(p) + assert len(loaded.accounts) == 1 + assert loaded.accounts[0].name == "Test" + assert loaded.accounts[0].balance == Decimal("100.50") + assert loaded.transactions[0].amount == Decimal("33.33") + + def test_version_field_persisted(self, tmp_path: Path): + repo = JsonFileRepository() + model = FinancialModel() + p = tmp_path / "v.json" + repo.save(model, p) + import json + with open(p) as f: + data = json.load(f) + assert data["version"] == 1 + + def test_creates_parent_dirs(self, tmp_path: Path): + repo = JsonFileRepository() + model = FinancialModel() + p = tmp_path / "nested" / "dir" / "model.json" + repo.save(model, p) + assert p.exists() + + def test_implements_protocol(self): + repo = JsonFileRepository() + assert isinstance(repo, ModelRepository) + + def test_decimal_preserved(self, tmp_path: Path): + repo = JsonFileRepository() + model = FinancialModel( + assets=[Asset(name="Stocks", value=Decimal("12345.67"), growth_rate=Decimal("8.5"))] + ) + p = tmp_path / "decimal.json" + repo.save(model, p) + loaded = repo.load(p) + assert loaded.assets[0].value == Decimal("12345.67") + assert loaded.assets[0].growth_rate == Decimal("8.5")