From e9c71203233a73aa768e9c4dbef338212a73ec57 Mon Sep 17 00:00:00 2001 From: oqyude Date: Wed, 22 Jul 2026 12:12:09 +0300 Subject: [PATCH] testing new features --- .agent/checkpoints.json | 21 +- .agent/task-manifest.json | 194 +++++------------ ai/assistant.py | 17 +- ai/prompts.py | 51 ++--- cashflow_model/__init__.py | 5 + cashflow_model/currency.py | 79 +++++++ cashflow_model/model.py | 7 + cli/config.py | 428 +++++++++++++++++++++++++++++++++++++ cli/i18n.py | 350 ++++++++++++++++++++++++++++++ cli/main.py | 261 ++++++++++++++++------ data/excel.xlsx | Bin 0 -> 9967 bytes data/model.json | 101 ++++++++- engine/forecast.py | 45 ++-- pyproject.toml | 2 +- sync/excel_sync.py | 29 ++- tests/conftest.py | 15 +- tests/test_cli.py | 145 ++++++++++++- tests/test_currency.py | 72 +++++++ tests/test_excel_sync.py | 10 + tests/test_i18n.py | 44 ++++ tests/test_model.py | 31 +++ 21 files changed, 1614 insertions(+), 293 deletions(-) create mode 100644 cashflow_model/currency.py create mode 100644 cli/config.py create mode 100644 cli/i18n.py create mode 100644 data/excel.xlsx create mode 100644 tests/test_currency.py create mode 100644 tests/test_i18n.py diff --git a/.agent/checkpoints.json b/.agent/checkpoints.json index c1ca7ff..32b1c10 100644 --- a/.agent/checkpoints.json +++ b/.agent/checkpoints.json @@ -1,8 +1,8 @@ { "metaagent_version": "1.1.0", - "session_id": "metaagent-002", + "session_id": "metaagent-003", "target_repo": "S:\\Git\\nifodea", - "goal": "Обновление metaagent-артефактов до v1.0.0, валидация существующего кода и окружения", + "goal": "i18n (ru/en) — инфраструктура, обёртка строк, контроль переводов", "project_type": "existing", "config": { "depth": 4, @@ -18,17 +18,14 @@ "red_team": "skipped", "decomposition": "completed", "environment": "completed", - "handoff": "completed" + "handoff": "pending" }, "tasks": [ - { "id": "T1", "title": "Инициализация проекта и зависимостей", "status": "completed", "depends_on": [], "acceptance_criteria": ["pyproject.toml создан", "Все __init__.py созданы", "ruff проходит", "pytest запускается"] }, - { "id": "T2", "title": "Модель данных (dataclass + JSON)", "status": "completed", "depends_on": ["T1"], "acceptance_criteria": ["Все сущности dataclass", "FinancialModel save/load JSON"] }, - { "id": "T3", "title": "Forecast Engine", "status": "completed", "depends_on": ["T2"], "acceptance_criteria": ["forecast_cashflow работает", "recurring проецируются", "активы/обязательства учтены"] }, - { "id": "T4", "title": "Scenario Analysis", "status": "completed", "depends_on": ["T3"], "acceptance_criteria": ["3 сценария", "what-if модификация", "сравнение сценариев"] }, - { "id": "T5", "title": "Excel Sync", "status": "completed", "depends_on": ["T2"], "acceptance_criteria": ["импорт из Excel", "экспорт в Excel", "ошибки невалидного формата"] }, - { "id": "T6", "title": "CLI (Typer)", "status": "completed", "depends_on": ["T2","T3","T4","T5","T7"], "acceptance_criteria": ["init/forecast/analyze/import/export/scenario/whatif/compare команды"] }, - { "id": "T7", "title": "AI Assistant", "status": "completed", "depends_on": ["T3"], "acceptance_criteria": ["промпты с моделью и прогнозом", "заглушка ответа"] }, - { "id": "T8", "title": "Тесты", "status": "completed", "depends_on": ["T2","T3","T4","T5","T6","T7"], "acceptance_criteria": ["pytest проходит", "покрытие всех модулей"] } + { "id": "T9", "title": "i18n инфраструктура (cli/i18n.py)", "status": "completed", "depends_on": [], "acceptance_criteria": ["Translator, t(), setup_i18n(), set_lang()", "ru default, en fallback"] }, + { "id": "T10", "title": "Обёртка CLI-строк в t()", "status": "completed", "depends_on": ["T9"], "acceptance_criteria": ["main.py + config.py через t()", "ruff check проходит"] }, + { "id": "T11", "title": "AI-промпты через i18n", "status": "completed", "depends_on": ["T9"], "acceptance_criteria": ["prompts.py использует t()"] }, + { "id": "T12", "title": "Тесты i18n", "status": "completed", "depends_on": ["T9"], "acceptance_criteria": ["pytest проходит"] }, + { "id": "T13", "title": "Аудит и контроль актуальности переводов", "status": "pending", "depends_on": ["T9"], "acceptance_criteria": ["en-словарь синхронизирован с ru"] } ], - "last_updated": "2026-07-22T12:00:00Z" + "last_updated": "2026-07-22T18:00:00Z" } diff --git a/.agent/task-manifest.json b/.agent/task-manifest.json index eb018c5..2c43a54 100644 --- a/.agent/task-manifest.json +++ b/.agent/task-manifest.json @@ -1,171 +1,79 @@ { "$schema": "metaagent-task-manifest", "version": "1.0", - "session_id": "metaagent-002", - "goal": "Обновление metaagent-артефактов до v1.0.0, валидация существующего кода и окружения", - "created_at": "2026-07-12T20:00:00Z", + "session_id": "metaagent-003", + "goal": "i18n (ru/en) — инфраструктура и обёртка строк, контроль актуальности переводов", + "created_at": "2026-07-22T18:00:00Z", "tasks": [ { - "id": "T1", - "title": "Инициализация проекта и зависимостей", - "description": "Создать структуру директорий, pyproject.toml, venv, установить зависимости", - "type": "config", - "files": [ - "pyproject.toml", - "cashflow_model/__init__.py", - "sync/__init__.py", - "engine/__init__.py", - "ai/__init__.py", - "cli/__init__.py", - "data/.gitkeep", - "exports/.gitkeep" - ], + "id": "T9", + "title": "i18n инфраструктура (cli/i18n.py)", + "description": "Создан модуль cli/i18n.py с Translator, t(), set_lang(), setup_i18n(). Язык: CF_LANG (env), по умолчанию 'ru'. Русские переводы — полные, английский — скелет (fallback на ru).", + "type": "feature", + "files": ["cli/i18n.py"], "depends_on": [], "acceptance_criteria": [ - "pyproject.toml создан с правильными зависимостями", - "Все директории модулей созданы с __init__.py", - "ruff lint проходит без ошибок", - "pytest запускается" + "t() возвращает русский текст при CF_LANG=ru", + "t() возвращает русский текст при CF_LANG=en (fallback)", + "t('nonexistent') возвращает 'nonexistent'", + "setup_i18n() читает CF_LANG из окружения" ], "status": "completed" }, { - "id": "T2", - "title": "Модель данных (dataclass + JSON serialization)", - "description": "Реализовать все сущности: Account, Transaction, RecurringCashflow, Asset, Liability, ForecastScenario, FinancialModel", - "type": "feature", - "files": [ - "cashflow_model/__init__.py", - "cashflow_model/account.py", - "cashflow_model/transaction.py", - "cashflow_model/recurring.py", - "cashflow_model/asset.py", - "cashflow_model/liability.py", - "cashflow_model/scenario.py", - "cashflow_model/model.py" - ], - "depends_on": ["T1"], + "id": "T10", + "title": "Обёртка CLI-строк в t()", + "description": "Все user-facing строки в cli/main.py и cli/config.py заменены на вызовы t('key', ...). Русский словарь содержит ~150 ключей.", + "type": "refactor", + "files": ["cli/main.py", "cli/config.py"], + "depends_on": ["T9"], "acceptance_criteria": [ - "Все сущности — dataclass с правильными полями и типами", - "FinancialModel корректно сохраняется и загружается из JSON", - "Создание Account, Transaction, Asset, Liability через конструктор работает" + "Все help-строки typer.Option/Argument через t()", + "Все console.print сообщения через t()", + "Все docstrings оставлены как комментарии (typer не использует)", + "ruff check проходит" ], "status": "completed" }, { - "id": "T3", - "title": "Forecast Engine (базовый прогноз)", - "description": "Реализовать ForecastService с методами forecast_cashflow, apply_recurring, project_balance", - "type": "feature", - "files": [ - "engine/__init__.py", - "engine/forecast.py" - ], - "depends_on": ["T2"], + "id": "T11", + "title": "AI-промпты через i18n", + "description": "prompt.analyze, prompt.advice, prompt.scenario_comparison добавлены в словарь i18n, prompts.py использует t().", + "type": "refactor", + "files": ["ai/prompts.py"], + "depends_on": ["T9"], "acceptance_criteria": [ - "forecast_cashflow(months=12) возвращает список помесячных балансов", - "Регулярные платежи корректно проецируются на будущие периоды", - "Активы учитываются с ростом (growth_rate)", - "Обязательства учитываются с процентами и платежами" + "ANALYZE_PROMPT = t('prompt.analyze')", + "ADVICE_PROMPT = t('prompt.advice')", + "SCENARIO_COMPARISON_PROMPT = t('prompt.scenario_comparison')" ], "status": "completed" }, { - "id": "T4", - "title": "Scenario Analysis", - "description": "Реализовать ScenarioService с методами: сценарии, what-if, сравнение", - "type": "feature", - "files": [ - "engine/__init__.py", - "engine/scenarios.py" - ], - "depends_on": ["T3"], - "acceptance_criteria": [ - "Три предустановленных сценария (baseline, optimistic, pessimistic)", - "What-if: изменение параметров (доход +10%, расход -5%)", - "Сравнение сценариев возвращает сводку различий" - ], - "status": "completed" - }, - { - "id": "T5", - "title": "Excel Sync (import/export)", - "description": "Реализовать ExcelSync: чтение модели из .xlsx, запись результатов прогноза в .xlsx", - "type": "feature", - "files": [ - "sync/__init__.py", - "sync/excel_sync.py" - ], - "depends_on": ["T2"], - "acceptance_criteria": [ - "Импорт из Excel заполняет FinancialModel", - "Экспорт FinancialModel в Excel создаёт корректный .xlsx", - "Обработка ошибок при невалидном формате Excel" - ], - "status": "completed" - }, - { - "id": "T6", - "title": "CLI (Typer) — все команды", - "description": "Реализовать CLI через Typer с командами: init, import, export, forecast, scenario, analyze, whatif, compare", - "type": "feature", - "files": [ - "cli/__init__.py", - "cli/main.py", - "pyproject.toml" - ], - "depends_on": ["T2", "T3", "T4", "T5", "T7"], - "acceptance_criteria": [ - "Команда 'cf init' создаёт пустую модель и JSON", - "Команда 'cf forecast --months 12' выводит таблицу прогноза", - "Команда 'cf analyze' вызывает AI Assistant", - "Команда 'cf import' и 'cf export' работают с Excel", - "Команда 'cf scenario' применяет и выводит сценарий", - "Команда 'cf whatif' выполняет what-if анализ", - "Команда 'cf compare' сравнивает сценарии" - ], - "status": "completed" - }, - { - "id": "T7", - "title": "AI Assistant (промпты + интерфейс)", - "description": "Реализовать AssistantService: генерация промптов, заглушка для вызова AI API", - "type": "feature", - "files": [ - "ai/__init__.py", - "ai/prompts.py", - "ai/assistant.py" - ], - "depends_on": ["T3"], - "acceptance_criteria": [ - "Промпт 'analyze' включает модель и прогноз в JSON", - "Промпт 'advice' формирует запрос на финансовые рекомендации", - "AssistantService возвращает структурированный ответ (заглушка)" - ], - "status": "completed" - }, - { - "id": "T8", - "title": "Тесты на все модули", - "description": "Написать pytest-тесты для всех модулей", + "id": "T12", + "title": "Тесты i18n", + "description": "test_i18n.py: базовые тесты Translator, t(), set_lang, fallback, неизвестный ключ.", "type": "test", - "files": [ - "tests/test_model.py", - "tests/test_forecast.py", - "tests/test_scenarios.py", - "tests/test_excel_sync.py", - "tests/test_cli.py", - "tests/test_ai.py", - "tests/conftest.py" - ], - "depends_on": ["T2", "T3", "T4", "T5", "T6", "T7"], + "files": ["tests/test_i18n.py"], + "depends_on": ["T9"], "acceptance_criteria": [ - "pytest запускается и все тесты проходят", - "Покрытие базовых сценариев для каждой сущности", - "Roundtrip-тест Excel: export → import → compare", - "Forecast-тест: известные входные данные → ожидаемый результат" + "pytest tests/test_i18n.py проходит", + "Покрытие: ru default, en fallback, неизвестный ключ, format args" ], "status": "completed" + }, + { + "id": "T13", + "title": "Аудит и контроль актуальности переводов", + "description": "Периодическая проверка: все ли ключи из TRANSLATIONS['ru'] имеют соответствующий перевод в TRANSLATIONS['en']. При добавлении новых фич — новые ключи должны добавляться в оба словаря.", + "type": "audit", + "files": ["cli/i18n.py"], + "depends_on": ["T9"], + "acceptance_criteria": [ + "Все ru-ключи имеют en-перевод или fallback", + "При добавлении нового t('key') он регистрируется в _r()" + ], + "status": "pending" } ] } diff --git a/ai/assistant.py b/ai/assistant.py index f21ee74..8321927 100644 --- a/ai/assistant.py +++ b/ai/assistant.py @@ -1,7 +1,7 @@ import json from ai import prompts -from cashflow_model import FinancialModel +from cashflow_model import CurrencyConverter, FinancialModel from engine.forecast import ForecastService @@ -10,8 +10,15 @@ class AssistantError(Exception): class AssistantService: - def __init__(self, model: FinancialModel): + def __init__( + self, + model: FinancialModel, + converter: CurrencyConverter | None = None, + display_currency: str | None = None, + ): self.model = model + self.converter = converter or CurrencyConverter(model.exchange_rates) + self.display_currency = display_currency or model.base_currency def analyze(self, months: int = 12) -> dict: forecast_service = ForecastService(self.model) @@ -22,6 +29,8 @@ class AssistantService: model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), months=months, + base_currency=self.model.base_currency, + display_currency=self.display_currency, ) return { @@ -39,6 +48,8 @@ class AssistantService: model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), question=question, + base_currency=self.model.base_currency, + display_currency=self.display_currency, ) return { @@ -49,6 +60,8 @@ class AssistantService: def compare_scenarios(self, scenarios_json: str) -> dict: prompt = prompts.SCENARIO_COMPARISON_PROMPT.format( scenarios_json=scenarios_json, + base_currency=self.model.base_currency, + display_currency=self.display_currency, ) return { "prompt": prompt, diff --git a/ai/prompts.py b/ai/prompts.py index 7734abe..02e4b43 100644 --- a/ai/prompts.py +++ b/ai/prompts.py @@ -1,46 +1,21 @@ -ANALYZE_PROMPT = """ -Ты — финансовый AI-ассистент. Проанализируй финансовую модель пользователя. +from cli.i18n import t -### Модель (JSON): -{model_json} - -### Прогноз на {months} месяцев: -{forecast_json} - -Дай анализ по пунктам: -1. Общее финансовое состояние -2. Тренд денежного потока (рост/падение) -3. Достаточность ликвидности -4. Рекомендации по улучшению -""" - -ADVICE_PROMPT = """ -Ты — финансовый AI-ассистент. Дай персональные рекомендации. - -### Модель: -{model_json} - -### Прогноз: -{forecast_json} - -Вопрос пользователя: {question} - -Ответь как опытный финансовый консультант. -""" - -SCENARIO_COMPARISON_PROMPT = """ -Ты — финансовый AI-ассистент. Сравни сценарии прогноза. - -### Результаты сценариев: -{scenarios_json} - -Дай рекомендацию: какой сценарий наиболее вероятен и почему. -""" +ANALYZE_PROMPT = t("prompt.analyze") +ADVICE_PROMPT = t("prompt.advice") +SCENARIO_COMPARISON_PROMPT = t("prompt.scenario_comparison") -def format_context(model_json: str, forecast_json: str, months: int = 12) -> str: +def format_context( + model_json: str, + forecast_json: str, + months: int = 12, + base_currency: str = "RUB", + display_currency: str = "RUB", +) -> str: return ANALYZE_PROMPT.format( model_json=model_json, forecast_json=forecast_json, months=months, + base_currency=base_currency, + display_currency=display_currency, ) diff --git a/cashflow_model/__init__.py b/cashflow_model/__init__.py index 89c7fb4..033ab9b 100644 --- a/cashflow_model/__init__.py +++ b/cashflow_model/__init__.py @@ -1,5 +1,6 @@ from cashflow_model.account import Account from cashflow_model.asset import Asset +from cashflow_model.currency import CURRENCY_SYMBOLS, CurrencyConverter, CurrencyError, ExchangeRate from cashflow_model.liability import Liability from cashflow_model.model import FinancialModel from cashflow_model.recurring import RecurringCashflow @@ -14,4 +15,8 @@ __all__ = [ "Liability", "ForecastScenario", "FinancialModel", + "ExchangeRate", + "CurrencyConverter", + "CurrencyError", + "CURRENCY_SYMBOLS", ] diff --git a/cashflow_model/currency.py b/cashflow_model/currency.py new file mode 100644 index 0000000..1a0ea53 --- /dev/null +++ b/cashflow_model/currency.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass + +CURRENCY_SYMBOLS = { + "RUB": "₽", + "USD": "$", + "EUR": "€", + "GBP": "£", + "CNY": "¥", + "JPY": "¥", + "KZT": "₸", + "UAH": "₴", +} + + +@dataclass +class ExchangeRate: + from_currency: str = "USD" + to_currency: str = "RUB" + rate: float = 80.0 + + def to_dict(self) -> dict: + return { + "from_currency": self.from_currency, + "to_currency": self.to_currency, + "rate": self.rate, + } + + @classmethod + def from_dict(cls, data: dict) -> "ExchangeRate": + return cls( + from_currency=data.get("from_currency", "USD"), + to_currency=data.get("to_currency", "RUB"), + rate=data.get("rate", 80.0), + ) + + +DEFAULT_RATES: list[ExchangeRate] = [ + ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0), +] + + +class CurrencyError(Exception): + pass + + +class CurrencyConverter: + def __init__(self, rates: list[ExchangeRate] | None = None): + self._rates: dict[tuple[str, str], float] = {} + if rates: + for r in rates: + self.set_rate(r.from_currency, r.to_currency, r.rate) + + def set_rate(self, from_currency: str, to_currency: str, rate: float) -> None: + if rate <= 0: + raise CurrencyError(f"Rate must be positive: {rate}") + self._rates[(from_currency, to_currency)] = rate + inverse = 1.0 / rate + self._rates[(to_currency, from_currency)] = inverse + + def get_rate(self, from_currency: str, to_currency: str) -> float: + if from_currency == to_currency: + return 1.0 + try: + return self._rates[(from_currency, to_currency)] + except KeyError: + raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}") + + def convert(self, amount: float, from_currency: str, to_currency: str) -> float: + if from_currency == to_currency: + return amount + rate = self.get_rate(from_currency, to_currency) + return round(amount * rate, 2) + + def get_symbol(self, currency: str) -> str: + return CURRENCY_SYMBOLS.get(currency, currency) + + @classmethod + def with_defaults(cls) -> "CurrencyConverter": + return cls(DEFAULT_RATES) diff --git a/cashflow_model/model.py b/cashflow_model/model.py index 109e366..77d97d2 100644 --- a/cashflow_model/model.py +++ b/cashflow_model/model.py @@ -4,6 +4,7 @@ from pathlib import Path from cashflow_model.account import Account from cashflow_model.asset import Asset +from cashflow_model.currency import DEFAULT_RATES, ExchangeRate from cashflow_model.liability import Liability from cashflow_model.recurring import RecurringCashflow from cashflow_model.scenario import ForecastScenario @@ -12,32 +13,38 @@ from cashflow_model.transaction import Transaction @dataclass class FinancialModel: + base_currency: str = "RUB" accounts: list[Account] = field(default_factory=list) transactions: list[Transaction] = field(default_factory=list) recurring: list[RecurringCashflow] = field(default_factory=list) assets: list[Asset] = field(default_factory=list) liabilities: list[Liability] = field(default_factory=list) scenarios: list[ForecastScenario] = field(default_factory=list) + exchange_rates: list[ExchangeRate] = field(default_factory=lambda: DEFAULT_RATES.copy()) def to_dict(self) -> dict: return { + "base_currency": self.base_currency, "accounts": [a.to_dict() for a in self.accounts], "transactions": [t.to_dict() for t in self.transactions], "recurring": [r.to_dict() for r in self.recurring], "assets": [a.to_dict() for a in self.assets], "liabilities": [li.to_dict() for li in self.liabilities], "scenarios": [s.to_dict() for s in self.scenarios], + "exchange_rates": [r.to_dict() for r in self.exchange_rates], } @classmethod def from_dict(cls, data: dict) -> "FinancialModel": return cls( + base_currency=data.get("base_currency", "RUB"), accounts=[Account.from_dict(a) for a in data.get("accounts", [])], transactions=[Transaction.from_dict(t) for t in data.get("transactions", [])], recurring=[RecurringCashflow.from_dict(r) for r in data.get("recurring", [])], assets=[Asset.from_dict(a) for a in data.get("assets", [])], liabilities=[Liability.from_dict(li) for li in data.get("liabilities", [])], scenarios=[ForecastScenario.from_dict(s) for s in data.get("scenarios", [])], + exchange_rates=[ExchangeRate.from_dict(r) for r in data.get("exchange_rates", [])], ) def save(self, path: str | Path) -> None: diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000..ca2c27e --- /dev/null +++ b/cli/config.py @@ -0,0 +1,428 @@ +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from cashflow_model import ( + Account, + Asset, + ExchangeRate, + FinancialModel, + Liability, + RecurringCashflow, + Transaction, +) +from cli.i18n import t + +app = typer.Typer(name="config", help=t("config.help")) +console = Console() + +DATA_DIR = Path("data") +MODEL_PATH = DATA_DIR / "model.json" + + +def _load_model() -> FinancialModel: + if MODEL_PATH.exists(): + return FinancialModel.load(MODEL_PATH) + return FinancialModel() + + +def _save_model(model: FinancialModel) -> None: + model.save(MODEL_PATH) + + +# ── base-currency ────────────────────────────────────────────── + + +@app.command() +def base_currency( + currency: str = typer.Argument(None, help=t("cmd.config.base_currency.arg")), +) -> None: + """Показать или установить базовую валюту модели""" + model = _load_model() + if currency: + model.base_currency = currency.upper() + _save_model(model) + ok = t("global.ok") + msg = t("cmd.config.base_currency.ok", currency=model.base_currency) + console.print(f"[green]{ok}[/green] {msg}") + else: + console.print(t("cmd.config.base_currency.show", currency=f"[bold]{model.base_currency}[/bold]")) + + +# ── rate ─────────────────────────────────────────────────────── + + +@app.command() +def rate_set( + from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")), + to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")), + rate: float = typer.Argument(..., help=t("cmd.config.rate_set.arg.rate")), +) -> None: + """Добавить или обновить курс валюты""" + model = _load_model() + fc, tc = from_currency.upper(), to_currency.upper() + existing = [r for r in model.exchange_rates if r.from_currency == fc and r.to_currency == tc] + if existing: + existing[0].rate = rate + else: + model.exchange_rates.append(ExchangeRate( + from_currency=from_currency.upper(), + to_currency=to_currency.upper(), + rate=rate, + )) + _save_model(model) + fc, tc = from_currency.upper(), to_currency.upper() + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_set.ok', fc=fc, tc=tc, rate=rate)}") + + +@app.command(name="rate-list") +def rate_list() -> None: + """Список курсов валют""" + model = _load_model() + if not model.exchange_rates: + console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]") + return + table = Table(title=t("table.rates.title")) + table.add_column(t("table.rates.col.from"), style="cyan") + table.add_column(t("table.rates.col.to"), style="cyan") + table.add_column(t("table.rates.col.rate"), justify="right") + for r in model.exchange_rates: + table.add_row(r.from_currency, r.to_currency, str(r.rate)) + console.print(table) + + +@app.command(name="rate-remove") +def rate_remove( + from_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.from")), + to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")), +) -> None: + """Удалить курс валюты""" + model = _load_model() + fc, tc = from_currency.upper(), to_currency.upper() + initial = len(model.exchange_rates) + model.exchange_rates = [ + r for r in model.exchange_rates + if not (r.from_currency == fc and r.to_currency == tc) + ] + if len(model.exchange_rates) < initial: + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_remove.ok', fc=fc, tc=tc)}") + else: + console.print(f"[red]{t('cmd.config.rate_remove.err', fc=fc, tc=tc)}[/red]") + raise typer.Exit(1) + + +# ── account ──────────────────────────────────────────────────── + + +@app.command() +def account_add( + name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.account_add.opt.name")), + balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.account_add.opt.balance")), + currency: str = typer.Option("", "--currency", "-c", help=t("cmd.config.account_add.opt.currency")), +) -> None: + """Добавить счёт""" + model = _load_model() + curr = currency.upper() if currency else model.base_currency + account = Account(name=name, balance=balance, currency=curr) + model.accounts.append(account) + _save_model(model) + ok = t("global.ok") + msg = t("cmd.config.account_add.ok", name=name, balance=balance, currency=curr) + console.print(f"[green]{ok}[/green] {msg}") + + +@app.command(name="account-list") +def account_list() -> None: + """Список счетов""" + model = _load_model() + if not model.accounts: + console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]") + return + table = Table(title=t("table.accounts_config.title")) + table.add_column(t("table.accounts_config.col.id"), style="dim") + table.add_column(t("table.accounts_config.col.name"), style="cyan") + table.add_column(t("table.accounts_config.col.currency")) + table.add_column(t("table.accounts_config.col.balance"), justify="right") + for a in model.accounts: + table.add_row(str(a.id)[:8], a.name, a.currency, f"{a.balance:,.2f}") + console.print(table) + + +@app.command(name="account-remove") +def account_remove( + identifier: str = typer.Argument(..., help=t("cmd.config.account_remove.arg")), +) -> None: + """Удалить счёт по ID или названию""" + model = _load_model() + initial = len(model.accounts) + model.accounts = [ + a for a in model.accounts + if str(a.id) != identifier and a.name != identifier + ] + if len(model.accounts) < initial: + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.account_remove.ok', id=identifier)}") + else: + console.print(f"[red]{t('cmd.config.account_remove.err', id=identifier)}[/red]") + raise typer.Exit(1) + + +# ── transaction ──────────────────────────────────────────────── + + +@app.command() +def transaction_add( + account: str = typer.Option(..., "--account", "-a", help=t("cmd.config.transaction_add.opt.account")), + amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.transaction_add.opt.amount")), + category: str = typer.Option("", "--category", "-c", help=t("cmd.config.transaction_add.opt.category")), + description: str = typer.Option("", "--description", "-d", help=t("cmd.config.transaction_add.opt.description")), + date: str = typer.Option("", "--date", "-D", help=t("cmd.config.transaction_add.opt.date")), +) -> None: + """Добавить транзакцию""" + model = _load_model() + account_id = _resolve_account_id(model, account) + if not account_id: + console.print(f"[red]{t('cmd.config.transaction_add.err', account=account)}[/red]") + raise typer.Exit(1) + txn = Transaction( + date=date or "", + account=account_id, + category=category, + amount=amount, + description=description, + ) + model.transactions.append(txn) + _save_model(model) + kind = t("cmd.config.transaction_add.kind.income") if amount >= 0 else t("cmd.config.transaction_add.kind.expense") + ok = t("global.ok") + msg = t("cmd.config.transaction_add.ok", kind=kind, amount=abs(amount), account=account) + console.print(f"[green]{ok}[/green] {msg}") + + +@app.command(name="transaction-list") +def transaction_list() -> None: + """Список транзакций""" + model = _load_model() + if not model.transactions: + console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]") + return + table = Table(title=t("table.transactions.title")) + table.add_column(t("table.accounts_config.col.id"), style="dim") + table.add_column(t("table.transactions.col.date")) + table.add_column(t("table.accounts_config.col.name")) + table.add_column(t("table.transactions.col.category")) + table.add_column(t("table.transactions.col.amount"), justify="right") + table.add_column(t("table.transactions.col.description")) + for tx in model.transactions: + account_name = _account_name(model, tx.account) + table.add_row( + str(tx.id)[:8], tx.date, account_name, tx.category, + f"{tx.amount:,.2f}", tx.description, + ) + console.print(table) + + +@app.command(name="transaction-remove") +def transaction_remove( + identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")), +) -> None: + """Удалить транзакцию по ID""" + model = _load_model() + initial = len(model.transactions) + model.transactions = [t for t in model.transactions if str(t.id) != identifier] + if len(model.transactions) < initial: + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.transaction_remove.ok', id=identifier)}") + else: + console.print(f"[red]{t('cmd.config.transaction_remove.err', id=identifier)}[/red]") + raise typer.Exit(1) + + +# ── recurring ────────────────────────────────────────────────── + + +@app.command() +def recurring_add( + amount: float = typer.Option(..., "--amount", "-m", help=t("cmd.config.recurring_add.opt.amount")), + category: str = typer.Option("", "--category", "-c", help=t("cmd.config.recurring_add.opt.category")), + frequency: str = typer.Option("monthly", "--frequency", "-f", help=t("cmd.config.recurring_add.opt.frequency")), + start_date: str = typer.Option("", "--start", "-s", help=t("cmd.config.recurring_add.opt.start")), + end_date: str = typer.Option("", "--end", "-e", help=t("cmd.config.recurring_add.opt.end")), +) -> None: + """Добавить регулярный платёж""" + model = _load_model() + rc = RecurringCashflow( + start_date=start_date or "", end_date=end_date or "", + frequency=frequency, amount=amount, category=category, + ) + model.recurring.append(rc) + _save_model(model) + ok = t("global.ok") + msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency) + console.print(f"[green]{ok}[/green] {msg}") + + +@app.command(name="recurring-list") +def recurring_list() -> None: + """Список регулярных платежей""" + model = _load_model() + if not model.recurring: + console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]") + return + table = Table(title=t("table.recurring.title")) + table.add_column(t("table.recurring.col.id"), style="dim") + table.add_column(t("table.recurring.col.start")) + table.add_column(t("table.recurring.col.end")) + table.add_column(t("table.recurring.col.period")) + table.add_column(t("table.recurring.col.amount"), justify="right") + table.add_column(t("table.recurring.col.category")) + for r in model.recurring: + row = (str(r.id)[:8], r.start_date, r.end_date, r.frequency, f"{r.amount:,.2f}", r.category) + table.add_row(*row) + console.print(table) + + +@app.command(name="recurring-remove") +def recurring_remove( + identifier: str = typer.Argument(..., help=t("cmd.config.recurring_remove.arg")), +) -> None: + """Удалить регулярный платёж по ID""" + model = _load_model() + initial = len(model.recurring) + model.recurring = [r for r in model.recurring if str(r.id) != identifier] + if len(model.recurring) < initial: + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.recurring_remove.ok', id=identifier)}") + else: + console.print(f"[red]{t('cmd.config.recurring_remove.err', id=identifier)}[/red]") + raise typer.Exit(1) + + +# ── asset ────────────────────────────────────────────────────── + + +@app.command() +def asset_add( + name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.asset_add.opt.name")), + value: float = typer.Option(0.0, "--value", "-v", help=t("cmd.config.asset_add.opt.value")), + growth_rate: float = typer.Option(0.0, "--growth", "-g", help=t("cmd.config.asset_add.opt.growth")), +) -> None: + """Добавить актив""" + model = _load_model() + asset = Asset(name=name, value=value, growth_rate=growth_rate) + model.assets.append(asset) + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_add.ok', name=name, value=value)}") + + +@app.command(name="asset-list") +def asset_list() -> None: + """Список активов""" + model = _load_model() + if not model.assets: + console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]") + return + table = Table(title=t("table.assets.title")) + table.add_column(t("table.assets.col.id"), style="dim") + table.add_column(t("table.assets.col.name"), style="cyan") + table.add_column(t("table.assets.col.value"), justify="right") + table.add_column(t("table.assets.col.growth"), justify="right") + for a in model.assets: + table.add_row(str(a.id)[:8], a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%") + console.print(table) + + +@app.command(name="asset-remove") +def asset_remove( + identifier: str = typer.Argument(..., help=t("cmd.config.asset_remove.arg")), +) -> None: + """Удалить актив по ID или названию""" + model = _load_model() + initial = len(model.assets) + model.assets = [ + a for a in model.assets + if str(a.id) != identifier and a.name != identifier + ] + if len(model.assets) < initial: + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_remove.ok', id=identifier)}") + else: + console.print(f"[red]{t('cmd.config.asset_remove.err', id=identifier)}[/red]") + raise typer.Exit(1) + + +# ── liability ────────────────────────────────────────────────── + + +@app.command() +def liability_add( + name: str = typer.Option(..., "--name", "-n", help=t("cmd.config.liability_add.opt.name")), + balance: float = typer.Option(0.0, "--balance", "-b", help=t("cmd.config.liability_add.opt.balance")), + interest: float = typer.Option(0.0, "--interest", "-i", help=t("cmd.config.liability_add.opt.interest")), + payment: float = typer.Option(0.0, "--payment", "-p", help=t("cmd.config.liability_add.opt.payment")), +) -> None: + """Добавить обязательство""" + model = _load_model() + liability = Liability(name=name, balance=balance, interest=interest, payment=payment) + model.liabilities.append(liability) + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_add.ok', name=name, balance=balance)}") + + +@app.command(name="liability-list") +def liability_list() -> None: + """Список обязательств""" + model = _load_model() + if not model.liabilities: + console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]") + return + table = Table(title=t("table.liabilities.title")) + table.add_column(t("table.liabilities.col.id"), style="dim") + table.add_column(t("table.liabilities.col.name"), style="cyan") + table.add_column(t("table.liabilities.col.balance"), justify="right") + table.add_column(t("table.liabilities.col.rate"), justify="right") + table.add_column(t("table.liabilities.col.payment"), justify="right") + for li in model.liabilities: + b, i, p = f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}" + row = (str(li.id)[:8], li.name, b, i, p) + table.add_row(*row) + console.print(table) + + +@app.command(name="liability-remove") +def liability_remove( + identifier: str = typer.Argument(..., help=t("cmd.config.liability_remove.arg")), +) -> None: + """Удалить обязательство по ID или названию""" + model = _load_model() + initial = len(model.liabilities) + model.liabilities = [ + li for li in model.liabilities + if str(li.id) != identifier and li.name != identifier + ] + if len(model.liabilities) < initial: + _save_model(model) + console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_remove.ok', id=identifier)}") + else: + console.print(f"[red]{t('cmd.config.liability_remove.err', id=identifier)}[/red]") + raise typer.Exit(1) + + +# ── helpers ──────────────────────────────────────────────────── + + +def _resolve_account_id(model: FinancialModel, identifier: str) -> str | None: + for a in model.accounts: + if str(a.id) == identifier or a.name == identifier: + return str(a.id) + return None + + +def _account_name(model: FinancialModel, account_id: str) -> str: + for a in model.accounts: + if str(a.id) == account_id: + return a.name + return account_id[:8] diff --git a/cli/i18n.py b/cli/i18n.py new file mode 100644 index 0000000..9d7c753 --- /dev/null +++ b/cli/i18n.py @@ -0,0 +1,350 @@ +import os + +_TRANSLATIONS: dict[str, dict[str, str]] = {} + + +def _r(key: str, text: str) -> str: + _TRANSLATIONS.setdefault("ru", {})[key] = text + return text + + +def _e(key: str, text: str) -> str: + _TRANSLATIONS.setdefault("en", {})[key] = text + return text + + +# ── app ──────────────────────────────────────────────────────── + +_r("app.help", "CashFlow Forecast — личная финансовая модель") + +# ── cmd.init ─────────────────────────────────────────────────── + +_r("cmd.init.help", "Создать пустую финансовую модель") +_r("cmd.init.ok", "Пустая модель создана в {path}") + +# ── cmd.forecast ─────────────────────────────────────────────── + +_r("cmd.forecast.help", "Запустить прогноз денежных потоков") +_r("cmd.forecast.opt.months", "Количество месяцев прогноза") +_r("cmd.forecast.opt.currency", "Валюта отображения (по умолчанию — базовая валюта модели)") +_r("cmd.forecast.title", "Прогноз на {months} мес. ({currency})") +_r("cmd.forecast.col.account", "Счёт") +_r("cmd.forecast.col.month", "Месяц") +_r("cmd.forecast.col.balance", "Баланс") +_r("cmd.forecast.col.income", "Доход") +_r("cmd.forecast.col.expenses", "Расход") +_r("cmd.forecast.total", "Итог: Баланс: {balance} | Доход: {income} | Расход: {expenses}") + +# ── cmd.scenario ────────────────────────────────────────────── + +_r("cmd.scenario.help", "Применить сценарий и показать прогноз") +_r("cmd.scenario.arg.name", "Имя сценария: baseline, optimistic, pessimistic") +_r("cmd.scenario.opt.months", "Количество месяцев") +_r("cmd.scenario.err.unknown", "Неизвестный сценарий: {name}") +_r("cmd.scenario.err.available", "Доступны: {scenarios}") +_r("cmd.scenario.label", "Сценарий") +_r("cmd.scenario.label.balance", "Баланс") +_r("cmd.scenario.label.income", "Доход") +_r("cmd.scenario.label.expenses", "Расход") + +# ── cmd.whatif ──────────────────────────────────────────────── + +_r("cmd.whatif.help", "What-if анализ с произвольными множителями") +_r("cmd.whatif.opt.income", "Множитель дохода") +_r("cmd.whatif.opt.expense", "Множитель расхода") +_r("cmd.whatif.opt.growth", "Множитель роста активов") +_r("cmd.whatif.opt.months", "Количество месяцев") +_r("cmd.whatif.title", "What-if анализ") +_r("cmd.whatif.params", "Доход x{income} | Расход x{expense} | Рост x{growth}") + +# ── cmd.compare ─────────────────────────────────────────────── + +_r("cmd.compare.help", "Сравнить все сценарии") +_r("cmd.compare.opt.months", "Количество месяцев") +_r("cmd.compare.title", "Сравнение сценариев ({currency})") +_r("cmd.compare.col.scenario", "Сценарий") + +# ── cmd.import ──────────────────────────────────────────────── + +_r("cmd.import.help", "Импорт данных из Excel") +_r("cmd.import.arg.path", "Путь к .xlsx файлу") +_r("cmd.import.ok", + "Импортировано: {accounts} счетов, {transactions} транзакций, " + "{recurring} регулярных платежей, {assets} активов, {liabilities} обязательств") +_r("cmd.import.base_currency", "Базовая валюта: {currency}") +_r("cmd.import.err", "Ошибка импорта: {error}") + +# ── cmd.export ──────────────────────────────────────────────── + +_r("cmd.export.help", "Экспорт модели в Excel") +_r("cmd.export.arg.path", "Путь для .xlsx файла") +_r("cmd.export.ok", "Модель экспортирована в {path}") +_r("cmd.export.err", "Ошибка экспорта: {error}") + +# ── cmd.analyze ─────────────────────────────────────────────── + +_r("cmd.analyze.help", "AI-анализ финансовой модели") +_r("cmd.analyze.opt.months", "Количество месяцев для анализа") +_r("cmd.analyze.title.prompt", "Промпт для AI") +_r("cmd.analyze.title.summary", "Сводка") +_r("cmd.analyze.base_currency", "Базовая валюта: {currency}") +_r("cmd.analyze.stub", "AI-ответ: заглушка. Подключите реальный API в ai/assistant.py") + +# ── cmd.info ────────────────────────────────────────────────── + +_r("cmd.info.help", "Сводка всей финансовой модели") +_r("cmd.info.metadata", "Метаданные") +_r("cmd.info.base_currency", "Базовая валюта: {currency} ({symbol})") +_r("cmd.info.accounts", "Счетов: {count}") +_r("cmd.info.transactions", "Транзакций: {count}") +_r("cmd.info.recurring", "Регулярных платежей: {count}") +_r("cmd.info.assets", "Активов: {count}") +_r("cmd.info.liabilities", "Обязательств: {count}") +_r("cmd.info.rates", "Курсов валют: {count}") +_r("cmd.info.more", "... и ещё {count} транзакций") + +# ── table headers ───────────────────────────────────────────── + +_r("table.accounts.title", "Счета") +_r("table.accounts_config.title", "Счета") +_r("table.accounts.col.name", "Название") +_r("table.accounts.col.currency", "Валюта") +_r("table.accounts.col.balance", "Баланс") + +_r("table.transactions.title", "Транзакции") +_r("table.transactions.col.date", "Дата") +_r("table.transactions.col.category", "Категория") +_r("table.transactions.col.amount", "Сумма") +_r("table.transactions.col.description", "Описание") + +_r("table.recurring.title", "Регулярные платежи") +_r("table.recurring.col.period", "Период") +_r("table.recurring.col.amount", "Сумма") +_r("table.recurring.col.category", "Категория") +_r("table.recurring.col.id", "ID") +_r("table.recurring.col.start", "Начало") +_r("table.recurring.col.end", "Конец") + +_r("table.assets.title", "Активы") +_r("table.assets.col.id", "ID") +_r("table.assets.col.name", "Название") +_r("table.assets.col.value", "Стоимость") +_r("table.assets.col.growth", "Рост (%)") + +_r("table.liabilities.title", "Обязательства") +_r("table.liabilities.col.id", "ID") +_r("table.liabilities.col.name", "Название") +_r("table.liabilities.col.balance", "Долг") +_r("table.liabilities.col.rate", "Ставка") +_r("table.liabilities.col.payment", "Платёж") + +_r("table.rates.title", "Курсы валют") +_r("table.rates.col.from", "Из") +_r("table.rates.col.to", "В") +_r("table.rates.col.rate", "Курс") + +_r("table.accounts_config.col.id", "ID") +_r("table.accounts_config.col.name", "Название") +_r("table.accounts_config.col.currency", "Валюта") +_r("table.accounts_config.col.balance", "Баланс") + +# ── config ──────────────────────────────────────────────────── + +_r("config.help", "Управление моделью (счета, транзакции, валюты...)") + +_r("cmd.config.base_currency.help", "Показать или установить базовую валюту модели") +_r("cmd.config.base_currency.arg", "Код валюты (RUB, USD, EUR...)") +_r("cmd.config.base_currency.ok", "Базовая валюта установлена: {currency}") +_r("cmd.config.base_currency.show", "Базовая валюта: {currency}") + +_r("cmd.config.rate_set.help", "Добавить или обновить курс валюты") +_r("cmd.config.rate_set.arg.from", "Из валюты") +_r("cmd.config.rate_set.arg.to", "В валюту") +_r("cmd.config.rate_set.arg.rate", "Курс") +_r("cmd.config.rate_set.ok", "Курс {fc} → {tc} = {rate}") + +_r("cmd.config.rate_list.help", "Список курсов валют") +_r("cmd.config.rate_list.empty", "Курсы не заданы") + +_r("cmd.config.rate_remove.help", "Удалить курс валюты") +_r("cmd.config.rate_remove.ok", "Курс {fc} → {tc} удалён") +_r("cmd.config.rate_remove.err", "Курс {fc} → {tc} не найден") + +_r("cmd.config.account_add.help", "Добавить счёт") +_r("cmd.config.account_add.opt.name", "Название счёта") +_r("cmd.config.account_add.opt.balance", "Начальный баланс") +_r("cmd.config.account_add.opt.currency", "Валюта счёта") +_r("cmd.config.account_add.ok", "Счёт '{name}' добавлен (баланс: {balance}, валюта: {currency})") + +_r("cmd.config.account_list.help", "Список счетов") +_r("cmd.config.account_list.empty", "Счета не добавлены") + +_r("cmd.config.account_remove.help", "Удалить счёт по ID или названию") +_r("cmd.config.account_remove.arg", "ID или название счёта") +_r("cmd.config.account_remove.ok", "Счёт '{id}' удалён") +_r("cmd.config.account_remove.err", "Счёт '{id}' не найден") + +_r("cmd.config.transaction_add.help", "Добавить транзакцию") +_r("cmd.config.transaction_add.opt.account", "ID или название счёта") +_r("cmd.config.transaction_add.opt.amount", "Сумма (доход/расход)") +_r("cmd.config.transaction_add.opt.category", "Категория") +_r("cmd.config.transaction_add.opt.description", "Описание") +_r("cmd.config.transaction_add.opt.date", "Дата (YYYY-MM-DD)") +_r("cmd.config.transaction_add.ok", "Транзакция '{kind}' на {amount:.2f} (счёт: {account})") +_r("cmd.config.transaction_add.kind.income", "доход") +_r("cmd.config.transaction_add.kind.expense", "расход") +_r("cmd.config.transaction_add.err", "Счёт '{account}' не найден") + +_r("cmd.config.transaction_list.help", "Список транзакций") +_r("cmd.config.transaction_list.empty", "Транзакции не добавлены") + +_r("cmd.config.transaction_remove.help", "Удалить транзакцию по ID") +_r("cmd.config.transaction_remove.arg", "ID транзакции") +_r("cmd.config.transaction_remove.ok", "Транзакция '{id}' удалена") +_r("cmd.config.transaction_remove.err", "Транзакция '{id}' не найдена") + +_r("cmd.config.recurring_add.help", "Добавить регулярный платёж") +_r("cmd.config.recurring_add.opt.amount", "Сумма") +_r("cmd.config.recurring_add.opt.category", "Категория") +_r("cmd.config.recurring_add.opt.frequency", "Периодичность") +_r("cmd.config.recurring_add.opt.start", "Дата начала (YYYY-MM-DD)") +_r("cmd.config.recurring_add.opt.end", "Дата окончания (YYYY-MM-DD)") +_r("cmd.config.recurring_add.ok", "Регулярный платёж на {amount:.2f} ({frequency})") + +_r("cmd.config.recurring_list.help", "Список регулярных платежей") +_r("cmd.config.recurring_list.empty", "Регулярные платежи не добавлены") + +_r("cmd.config.recurring_remove.help", "Удалить регулярный платёж по ID") +_r("cmd.config.recurring_remove.arg", "ID регулярного платежа") +_r("cmd.config.recurring_remove.ok", "Регулярный платёж '{id}' удалён") +_r("cmd.config.recurring_remove.err", "Регулярный платёж '{id}' не найден") + +_r("cmd.config.asset_add.help", "Добавить актив") +_r("cmd.config.asset_add.opt.name", "Название актива") +_r("cmd.config.asset_add.opt.value", "Стоимость") +_r("cmd.config.asset_add.opt.growth", "Годовой рост (%)") +_r("cmd.config.asset_add.ok", "Актив '{name}' добавлен (стоимость: {value})") + +_r("cmd.config.asset_list.help", "Список активов") +_r("cmd.config.asset_list.empty", "Активы не добавлены") + +_r("cmd.config.asset_remove.help", "Удалить актив по ID или названию") +_r("cmd.config.asset_remove.arg", "ID или название актива") +_r("cmd.config.asset_remove.ok", "Актив '{id}' удалён") +_r("cmd.config.asset_remove.err", "Актив '{id}' не найден") + +_r("cmd.config.liability_add.help", "Добавить обязательство") +_r("cmd.config.liability_add.opt.name", "Название") +_r("cmd.config.liability_add.opt.balance", "Остаток долга") +_r("cmd.config.liability_add.opt.interest", "Процентная ставка (%)") +_r("cmd.config.liability_add.opt.payment", "Ежемесячный платёж") +_r("cmd.config.liability_add.ok", "Обязательство '{name}' добавлено (долг: {balance})") + +_r("cmd.config.liability_list.help", "Список обязательств") +_r("cmd.config.liability_list.empty", "Обязательства не добавлены") + +_r("cmd.config.liability_remove.help", "Удалить обязательство по ID или названию") +_r("cmd.config.liability_remove.arg", "ID или название обязательства") +_r("cmd.config.liability_remove.ok", "Обязательство '{id}' удалено") +_r("cmd.config.liability_remove.err", "Обязательство '{id}' не найдено") + +# ── global ───────────────────────────────────────────────────── + +_r("global.ok", "OK") + +# ── prompts ─────────────────────────────────────────────────── + +_r("prompt.analyze", ( + "Ты — финансовый AI-ассистент. Проанализируй финансовую модель пользователя.\n" + "\n" + "### Модель (JSON):\n" + "{model_json}\n" + "\n" + "### Прогноз на {months} месяцев:\n" + "{forecast_json}\n" + "\n" + "### Валюта:\n" + "Базовая валюта модели: {base_currency}\n" + "Отображаемая валюта: {display_currency}\n" + "\n" + "Дай анализ по пунктам:\n" + "1. Общее финансовое состояние\n" + "2. Тренд денежного потока (рост/падение)\n" + "3. Достаточность ликвидности\n" + "4. Рекомендации по улучшению" +)) + +_r("prompt.advice", ( + "Ты — финансовый AI-ассистент. Дай персональные рекомендации.\n" + "\n" + "### Модель:\n" + "{model_json}\n" + "\n" + "### Прогноз:\n" + "{forecast_json}\n" + "\n" + "### Валюта:\n" + "Базовая валюта модели: {base_currency}\n" + "Отображаемая валюта: {display_currency}\n" + "\n" + "Вопрос пользователя: {question}\n" + "\n" + "Ответь как опытный финансовый консультант." +)) + +_r("prompt.scenario_comparison", ( + "Ты — финансовый AI-ассистент. Сравни сценарии прогноза.\n" + "\n" + "### Результаты сценариев:\n" + "{scenarios_json}\n" + "\n" + "### Валюта:\n" + "Базовая валюта модели: {base_currency}\n" + "Отображаемая валюта: {display_currency}\n" + "\n" + "Дай рекомендацию: какой сценарий наиболее вероятен и почему." +)) + + +_current_lang = "ru" + + +class Translator: + def __init__(self, lang: str | None = None): + self.lang = lang or _current_lang + if self.lang not in _TRANSLATIONS: + _TRANSLATIONS[self.lang] = {} + + def t(self, key: str, **kwargs) -> str: + src = _TRANSLATIONS.get(self.lang) or _TRANSLATIONS.get("ru") or {} + if key in src: + template = src[key] + elif "ru" in _TRANSLATIONS and key in _TRANSLATIONS["ru"]: + template = _TRANSLATIONS["ru"][key] + else: + return key + return template.format(**kwargs) if kwargs else template + + +_GLOBAL = Translator() + + +def setup_i18n() -> None: + lang = os.environ.get("CF_LANG", "ru") + if lang not in _TRANSLATIONS: + _TRANSLATIONS[lang] = {} + _GLOBAL.lang = lang + + +def set_lang(lang: str) -> None: + if lang not in _TRANSLATIONS: + _TRANSLATIONS[lang] = {} + _GLOBAL.lang = lang + + +def get_lang() -> str: + return _GLOBAL.lang + + +def t(key: str, **kwargs) -> str: + return _GLOBAL.t(key, **kwargs) diff --git a/cli/main.py b/cli/main.py index 69c3330..e317d31 100644 --- a/cli/main.py +++ b/cli/main.py @@ -6,7 +6,9 @@ from rich.console import Console from rich.table import Table from ai.assistant import AssistantService -from cashflow_model import FinancialModel +from cashflow_model import CurrencyConverter, FinancialModel +from cli.config import app as config_app +from cli.i18n import setup_i18n, t from engine.forecast import ForecastService from engine.scenarios import DEFAULT_SCENARIOS, ScenarioService from sync.excel_sync import ExcelSync @@ -16,12 +18,16 @@ try: except (AttributeError, OSError): pass -app = typer.Typer(name="cf", help="CashFlow Forecast - personal finance model") +setup_i18n() + +app = typer.Typer(name="cf", help=t("app.help")) console = Console() DATA_DIR = Path("data") MODEL_PATH = DATA_DIR / "model.json" +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) + def _load_model() -> FinancialModel: if MODEL_PATH.exists(): @@ -33,169 +39,292 @@ def _save_model(model: FinancialModel) -> None: model.save(MODEL_PATH) +def _get_converter(model: FinancialModel) -> CurrencyConverter: + return CurrencyConverter(model.exchange_rates) + + +def _fmt(amount: float, currency: str, symbol: str) -> str: + return f"{symbol}{amount:,.2f}" + + +def _resolve_currency(model: FinancialModel, currency: str | None) -> tuple[str, str]: + target = currency or model.base_currency + converter = _get_converter(model) + symbol = converter.get_symbol(target) + return target, symbol + + +def _convert_value( + converter: CurrencyConverter, amount: float, from_curr: str, to_curr: str +) -> float: + if to_curr == from_curr: + return amount + return converter.convert(amount, from_curr, to_curr) + + @app.command() def init() -> None: """Создать пустую финансовую модель""" model = FinancialModel() _save_model(model) - console.print("[green]OK[/green] Пустая модель создана в data/model.json") + console.print(t("cmd.init.ok", path=str(MODEL_PATH))) @app.command() def forecast( - months: int = typer.Option(12, "--months", "-m", help="Количество месяцев прогноза"), + months: int = typer.Option(12, "--months", "-m", help=t("cmd.forecast.opt.months")), + currency: str | None = CURRENCY_OPTION, ) -> None: """Запустить прогноз денежных потоков""" model = _load_model() + target_curr, symbol = _resolve_currency(model, currency) + converter = _get_converter(model) + base_curr = model.base_currency service = ForecastService(model) results = service.forecast_cashflow(months) summary = service.summary(months) + def _cv(val): + return _convert_value(converter, val, base_curr, target_curr) + + def _f(val): + return _fmt(val, target_curr, symbol) + if results: - table = Table(title=f"Прогноз на {months} мес.") - table.add_column("Счёт", style="cyan") - table.add_column("Месяц", style="white") - table.add_column("Баланс", justify="right", style="green") - table.add_column("Доход", justify="right") - table.add_column("Расход", justify="right") + table = Table(title=t("cmd.forecast.title", months=months, currency=target_curr)) + table.add_column(t("cmd.forecast.col.account"), style="cyan") + table.add_column(t("cmd.forecast.col.month"), style="white") + table.add_column(t("cmd.forecast.col.balance"), justify="right", style="green") + table.add_column(t("cmd.forecast.col.income"), justify="right") + table.add_column(t("cmd.forecast.col.expenses"), justify="right") for r in results: table.add_row( r["account"], str(r["month"]), - f"${r['balance']:.2f}", - f"${r['income']:.2f}", - f"${r['expenses']:.2f}", + _f(_cv(r["balance"])), + _f(_cv(r["income"])), + _f(_cv(r["expenses"])), ) console.print(table) - console.print(f"\n[bold]Итог:[/bold] Баланс: ${summary['total_balance']:.2f} | " - f"Доход: ${summary['total_income']:.2f} | " - f"Расход: ${summary['total_expenses']:.2f}") + cb = _cv(summary["total_balance"]) + ci = _cv(summary["total_income"]) + ce = _cv(summary["total_expenses"]) + console.print(t("cmd.forecast.total", balance=_f(cb), income=_f(ci), expenses=_f(ce))) @app.command() def scenario( - name: str = typer.Argument("baseline", help="Имя сценария: baseline, optimistic, pessimistic"), - months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), + name: str = typer.Argument("baseline", help=t("cmd.scenario.arg.name")), + months: int = typer.Option(12, "--months", "-m", help=t("cmd.scenario.opt.months")), + currency: str | None = CURRENCY_OPTION, ) -> None: """Применить сценарий и показать прогноз""" model = _load_model() + target_curr, symbol = _resolve_currency(model, currency) + converter = _get_converter(model) + base_curr = model.base_currency service = ScenarioService(model) if name in DEFAULT_SCENARIOS: scenario_obj = DEFAULT_SCENARIOS[name] else: - console.print(f"[red]Неизвестный сценарий: {name}[/red]") - console.print(f"Доступны: {', '.join(DEFAULT_SCENARIOS.keys())}") + console.print(f"[red]{t('cmd.scenario.err.unknown', name=name)}[/red]") + console.print(t("cmd.scenario.err.available", scenarios=", ".join(DEFAULT_SCENARIOS.keys()))) raise typer.Exit(1) result = service.apply(scenario_obj, months) - console.print(f"[bold]Сценарий:[/bold] {result['scenario']}") + balance = _convert_value(converter, result["total_balance"], base_curr, target_curr) + income = _convert_value(converter, result["total_income"], base_curr, target_curr) + expenses = _convert_value(converter, result["total_expenses"], base_curr, target_curr) + console.print(f"[bold]{t('cmd.scenario.label')}:[/bold] {result['scenario']}") console.print(f"[dim]{result['scenario_description']}[/dim]") - console.print(f"Баланс: ${result['total_balance']:.2f}") - console.print(f"Доход: ${result['total_income']:.2f}") - console.print(f"Расход: ${result['total_expenses']:.2f}") + console.print(f"{t('cmd.scenario.label.balance')}: {_fmt(balance, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.income')}: {_fmt(income, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.expenses')}: {_fmt(expenses, target_curr, symbol)}") @app.command() def whatif( - income_mult: float = typer.Option(1.0, "--income", "-i", help="Множитель дохода"), - expense_mult: float = typer.Option(1.0, "--expense", "-e", help="Множитель расхода"), - growth_mult: float = typer.Option(1.0, "--growth", "-g", help="Множитель роста активов"), - months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), + income_mult: float = typer.Option(1.0, "--income", "-i", help=t("cmd.whatif.opt.income")), + expense_mult: float = typer.Option(1.0, "--expense", "-e", help=t("cmd.whatif.opt.expense")), + growth_mult: float = typer.Option(1.0, "--growth", "-g", help=t("cmd.whatif.opt.growth")), + months: int = typer.Option(12, "--months", "-m", help=t("cmd.whatif.opt.months")), + currency: str | None = CURRENCY_OPTION, ) -> None: """What-if анализ с произвольными множителями""" model = _load_model() + target_curr, symbol = _resolve_currency(model, currency) + converter = _get_converter(model) + base_curr = model.base_currency service = ScenarioService(model) result = service.what_if(income_mult, expense_mult, growth_mult, months) - console.print("[bold]What-if анализ[/bold]") - console.print(f"Доход x{income_mult} | Расход x{expense_mult} | Рост x{growth_mult}") - console.print(f"Баланс: ${result['total_balance']:.2f}") - console.print(f"Доход: ${result['total_income']:.2f}") - console.print(f"Расход: ${result['total_expenses']:.2f}") + balance = _convert_value(converter, result["total_balance"], base_curr, target_curr) + income = _convert_value(converter, result["total_income"], base_curr, target_curr) + expenses = _convert_value(converter, result["total_expenses"], base_curr, target_curr) + console.print(f"[bold]{t('cmd.whatif.title')}[/bold]") + console.print(t("cmd.whatif.params", income=income_mult, expense=expense_mult, growth=growth_mult)) + console.print(f"{t('cmd.scenario.label.balance')}: {_fmt(balance, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.income')}: {_fmt(income, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.expenses')}: {_fmt(expenses, target_curr, symbol)}") @app.command() def compare( - months: int = typer.Option(12, "--months", "-m", help="Количество месяцев"), + months: int = typer.Option(12, "--months", "-m", help=t("cmd.compare.opt.months")), + currency: str | None = CURRENCY_OPTION, ) -> None: """Сравнить все сценарии""" model = _load_model() + target_curr, symbol = _resolve_currency(model, currency) + converter = _get_converter(model) + base_curr = model.base_currency service = ScenarioService(model) results = service.compare(months) - table = Table(title="Сравнение сценариев") - table.add_column("Сценарий", style="cyan") - table.add_column("Баланс", justify="right") - table.add_column("Доход", justify="right") - table.add_column("Расход", justify="right") + table = Table(title=t("cmd.compare.title", currency=target_curr)) + table.add_column(t("cmd.compare.col.scenario"), style="cyan") + table.add_column(t("cmd.forecast.col.balance"), justify="right") + table.add_column(t("cmd.forecast.col.income"), justify="right") + table.add_column(t("cmd.forecast.col.expenses"), justify="right") for name, r in results.items(): - table.add_row( - name, - f"${r['total_balance']:.2f}", - f"${r['total_income']:.2f}", - f"${r['total_expenses']:.2f}", - ) + b = _convert_value(converter, r["total_balance"], base_curr, target_curr) + i = _convert_value(converter, r["total_income"], base_curr, target_curr) + e = _convert_value(converter, r["total_expenses"], base_curr, target_curr) + fb = _fmt(b, target_curr, symbol) + fi = _fmt(i, target_curr, symbol) + fe = _fmt(e, target_curr, symbol) + table.add_row(name, fb, fi, fe) console.print(table) @app.command() def import_xlsx( - path: str = typer.Argument(..., help="Путь к .xlsx файлу"), + path: str = typer.Argument(..., help=t("cmd.import.arg.path")), ) -> None: """Импорт данных из Excel""" sync = ExcelSync() try: model = sync.import_model(path) _save_model(model) - console.print(f"[green]OK[/green] Импортировано: {len(model.accounts)} счетов, " - f"{len(model.transactions)} транзакций, " - f"{len(model.recurring)} регулярных платежей, " - f"{len(model.assets)} активов, " - f"{len(model.liabilities)} обязательств") + console.print(t("cmd.import.ok", + accounts=len(model.accounts), + transactions=len(model.transactions), + recurring=len(model.recurring), + assets=len(model.assets), + liabilities=len(model.liabilities))) + console.print(t("cmd.import.base_currency", currency=model.base_currency)) except Exception as e: - console.print(f"[red]Ошибка импорта: {e}[/red]") + console.print(f"[red]{t('cmd.import.err', error=str(e))}[/red]") raise typer.Exit(1) @app.command() def export_xlsx( - path: str = typer.Argument("exports/forecast.xlsx", help="Путь для .xlsx файла"), + path: str = typer.Argument("exports/forecast.xlsx", help=t("cmd.export.arg.path")), ) -> None: """Экспорт модели в Excel""" model = _load_model() sync = ExcelSync() try: sync.export_model(model, path) - console.print(f"[green]OK[/green] Модель экспортирована в {path}") + console.print(t("cmd.export.ok", path=path)) except Exception as e: - console.print(f"[red]Ошибка экспорта: {e}[/red]") + console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]") raise typer.Exit(1) @app.command() def analyze( - months: int = typer.Option(12, "--months", "-m", help="Количество месяцев для анализа"), + months: int = typer.Option(12, "--months", "-m", help=t("cmd.analyze.opt.months")), + currency: str | None = CURRENCY_OPTION, ) -> None: """AI-анализ финансовой модели""" model = _load_model() - assistant = AssistantService(model) + target_curr, _ = _resolve_currency(model, currency) + converter = _get_converter(model) + assistant = AssistantService(model, converter=converter, display_currency=target_curr) result = assistant.analyze(months) - console.print("[bold]Промпт для AI:[/bold]") + console.print(f"[bold]{t('cmd.analyze.title.prompt')}:[/bold]") console.print(result["prompt"][:500] + "...\n") - console.print("[bold]Сводка:[/bold]") - s = result["summary"] - console.print(f"Баланс: ${s['total_balance']:.2f}") - console.print(f"Доход: ${s['total_income']:.2f}") - console.print(f"Расход: ${s['total_expenses']:.2f}") - console.print( - "\n[yellow]AI-ответ: заглушка. Подключите реальный API в ai/assistant.py[/yellow]" - ) + console.print(f"[bold]{t('cmd.analyze.title.summary')}:[/bold]") + console.print(t("cmd.analyze.base_currency", currency=model.base_currency)) + console.print(f"\n[yellow]{t('cmd.analyze.stub')}[/yellow]") +@app.command() +def info() -> None: + """Сводка всей финансовой модели""" + model = _load_model() + converter = _get_converter(model) + symbol = converter.get_symbol(model.base_currency) + + console.print(f"[bold]{t('cmd.info.metadata')}[/bold]") + console.print(f" {t('cmd.info.base_currency', currency=model.base_currency, symbol=symbol)}") + console.print(f" {t('cmd.info.accounts', count=len(model.accounts))}") + console.print(f" {t('cmd.info.transactions', count=len(model.transactions))}") + console.print(f" {t('cmd.info.recurring', count=len(model.recurring))}") + console.print(f" {t('cmd.info.assets', count=len(model.assets))}") + console.print(f" {t('cmd.info.liabilities', count=len(model.liabilities))}") + console.print(f" {t('cmd.info.rates', count=len(model.exchange_rates))}") + + if model.accounts: + tbl = Table(title=t("table.accounts.title")) + tbl.add_column(t("table.accounts.col.name"), style="cyan") + tbl.add_column(t("table.accounts.col.currency")) + tbl.add_column(t("table.accounts.col.balance"), justify="right") + for a in model.accounts: + tbl.add_row(a.name, a.currency, f"{a.balance:,.2f}") + console.print(tbl) + + if model.transactions: + tbl = Table(title=t("table.transactions.title")) + tbl.add_column(t("table.transactions.col.date")) + tbl.add_column(t("table.transactions.col.category")) + tbl.add_column(t("table.transactions.col.amount"), justify="right") + tbl.add_column(t("table.transactions.col.description")) + for tx in model.transactions[:10]: + tbl.add_row(tx.date, tx.category, f"{tx.amount:,.2f}", tx.description) + if len(model.transactions) > 10: + console.print(tbl) + console.print(t("cmd.info.more", count=len(model.transactions) - 10)) + else: + console.print(tbl) + + if model.recurring: + tbl = Table(title=t("table.recurring.title")) + tbl.add_column(t("table.recurring.col.period")) + tbl.add_column(t("table.recurring.col.amount"), justify="right") + tbl.add_column(t("table.recurring.col.category")) + for r in model.recurring: + tbl.add_row(r.frequency, f"{r.amount:,.2f}", r.category) + console.print(tbl) + + if model.assets: + tbl = Table(title=t("table.assets.title")) + tbl.add_column(t("table.assets.col.name"), style="cyan") + tbl.add_column(t("table.assets.col.value"), justify="right") + tbl.add_column(t("table.assets.col.growth"), justify="right") + for a in model.assets: + tbl.add_row(a.name, f"{a.value:,.2f}", f"{a.growth_rate:.1f}%") + console.print(tbl) + + if model.liabilities: + tbl = Table(title=t("table.liabilities.title")) + tbl.add_column(t("table.liabilities.col.name"), style="cyan") + tbl.add_column(t("table.liabilities.col.balance"), justify="right") + tbl.add_column(t("table.liabilities.col.rate"), justify="right") + tbl.add_column(t("table.liabilities.col.payment"), justify="right") + for li in model.liabilities: + tbl.add_row(li.name, f"{li.balance:,.2f}", f"{li.interest:.1f}%", f"{li.payment:,.2f}") + console.print(tbl) + + +app.add_typer(config_app) + if __name__ == "__main__": app() diff --git a/data/excel.xlsx b/data/excel.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f9ca80bb1544f2042e758f709b04f29d345ab8d8 GIT binary patch literal 9967 zcmWIWW@Zs#U|`^2C`tJi<96KFV=4m!!&wFf1_1^JhLrr|fTH|@V*SK|0=4=vhsxVBE3!}wQVz8Kk7Zn zH8wiV>u^o^m)+@83%eFRsa|Jzx3OoV6#o)=Ip)KQa<4bE8uD12d9;1Q%PV?r>80nV zIhAj+3RFA%D8j^Qqq_X+J2G4X&#P4Jx`n+hr)#tR?+!jK8a(gWsr)}Ftozw_iKBX< zrHu2=YX$~}(~JxZg2-M-&M!&@`C;kAgMNn%c-rDqU+pcObx0s^+Gzpx2@__pJQ7pA z7q&KY@sSmGZ%>}Kmib`r@gM*H+zeat@lXk4ywh{TfZjsG9m{$T{RrKr{)tIpmaUa+ zx62mIJ7otmz0Pl7UVql{y?{lscY(*PtV;%5#rD#hCtsQs`O@Cmm~#zd&V@v8!BdM` zxVhrZW{>#-POMAU4>g%fJPCNhFY*HR?npc#vkArV)z+=~c7A8V< zB2P2Fl{3Co@)aogvg6dblhIr2ZrG%C8SguDGxpriw!7W0!tQM7zxZ|af2ESSYqx(D zJ+|4w^T+4>SKO!}Gc#t6vH%+c!yI)624MyUhKd~hl8n^cR0wGZ3Y)i4(Z#ncMC#tJ zzfh6>EtmV4S>v`X!o`_ox3|n}dFvxgIBZdS zQdsElou+OM?+TIA*J2(-OX(|Ld$r~E-LRI-oP`qZDG9Z4IvEZZLIR`m;}y0%l{fw& zdOD;xbupuGxTD(6jxC>l<{bTV>z-Ze`IlC*MvZv}(h&;v=kk|7@r&ws7;@;yfvj}< zu)5OM!eQmb0f}`)qFehc7D&PTO}sBQV$+~tgzU2`cij+ z49kPS-SZC3{gShPi6iHh#hDjUZhX77ZsM|4y}|yMzt`S>Jv(C#S8ufch*7e}fq)Raq zuASH18X(0n>qO&M_a?=och>*?I(_%aCB{{AYpu>@PpQvb&A9yS?%C%j|0#a)Wd8EW z*L|JN73|UB{P$RUvx_sEQ4YB9XKbhB4 zq`dp?w}{$r{_c6o*H<3ctaN>fn~LlF6S}*e2wH6Q!nNEGcMUw{bT(($COKEqO>eiy7Xo@#g@$7ALQ1n^vzm( zNKo6!E=kf%s%WcN+;EeTOV$1Ya*UD_h}CvuJHWV?hDZ_Z9EJ$G#)pWvi8b0@o` zbKMgJ_sg|ec+Gmds4#-%@4UFfo(Eqvolg6^qpt0$agI*kclRseUQhVdmfOEHGGSjl zE0S|>OIX(YRqZBrE98v0SU>KRvwK=q!DT+}*q(rOP1TJ5FWxdgwE9QW0n2*5+Ve}_ z@BCuB@X~g3<=XUvm%F3yx#dM4`Mu8Y)^!s-jpXp6vnP&+te>oT!*c$CH-9ti=6!to z>!wP}`dN{>%3>3|8#gVwu1&KD`ss+^}oE~lDz3C(ko&U+dB)obeYr|WlW z?68S`@^byc5<8vqKW*ZlZ#m0t?7Y3m`_;B7`?jiCczxM;rod0`G>>(<%_P}vyR6owYNahS5k=&d$Dmrf46Lwtxs3Y<)O?O z!s)9|-AmngFjV$@X!NHI`?74ei29xX7rFMtd>%nRF>WD`x}_B?)eywERrhfvuzekP-xyDe8%r5dI$`*dHIhf^oIBU0#*Z|vKv&u7nB zSEW?;YkK*?gin79PP+3ywSFG|CDwUe-M71sf8|ZK_VAfgc~p?G#Fa^TstlW$xY3h< zcatWt2{WFZa?<4kQ&Y+^nT8p^xc7eI*{LadZmpeVy{k@hn_t`|RUwhC4Lq!h;zf_& zzn`2{-gEtzPGfFMh*8Q_@l(#72W^duoNt7veqMJ=jl1>sqmZ+^rS_>Mcpt9Ho2#q- z|90FJ+b!a@Zl4#=d?2^Ey?A!jjzg|%{zqJs`)PFFvf#GWJ?6M)!aB}N>JHX+bu|dC zPTIF?z5h?uIo4aMKQ=1N{&Vg2l5ck(@YGyc9knz?ThQK{y+(c#TXIkI7nAsYk)*ZD zHm*H(%aEtCA>uR7!ZeHf?XI(=jIVaDvFzo_cf9KG)c*Dr-i&X&CNdLQ;|ynRGh6(` zkIUxHzxTWw4jt)x__ljLBWeZGF00MW#>BwT!ot8H0jWUB^NX^JGg4DaiuJ)1xE3i5 z@y@$#Ah5S4-l=*r(cXPO|63I0`T{77<+}5GGZIMGGi)$6z zOE=Y2cSEL3<-)f2duO`yNWO2~vyz)3wN_Jd{qwSgA*GE0b43Fl{qhMp@Rd(+(-E)s zqUKj#Oy{hFPEL5gOE@Iy)LWHzMbnKskA@kZdKeV-b(_w{J+TbJzjwa9dA`&1uE;9y z1uE-4u&r~54WG5Z`O51jTUg?P59)HJZOqqD#*Y;*rgS06n>ht>-ee(`HY!hpLdjH!$ufGMw91hyxZxVj=&b(V;d@T7h zqq_fETU2$$Ma3kxPLAl>tJ-^0<=mM=Q?59iI;$9~x$<*Jco65=&*J3rb61+r zC_X&$wOPRFJbznF|4UQ*+q<8w{CWM)yM6s()z|NBzkhqZbGxD1rhRcda}P7-wN*}k zaj`6Ui}VMXFY}rOeKTK8zb4FF#_f9&A&XesRKc!@bYkKd=6I z+h)u6v%$aD{xnW0?XgZWwb=OUmb%r|Ul}WSA8_Z;i?8*vot`8pLwhd87kGG{O?Db6;TL zowco+^<{6_6O*pZA9gerCwo5psZqE8uyauJ-bH(??-#Cn_18wy|GD(vxvOTy_AlCb zw@}!4f7qEjBL5zD7bR)!(A~cH=b93=coyGdg3C=h)zVDf^0RlRZ$I^L!Hu=Y7O&hL z`t8~7$(@?-^LG_)+;b@1?(227LUf zea~R3B;To|_2JtV%kt$^Uo7L~b-#RVsdXsldG`y!rZP{PS2X>*z57I6-rmlw=X{?s zz1_ay+p$|or4}J5CjXQSgx^JF#>F5TIIj`S5o?9z( zQ7}?#Mv&Ynd)6y&RbFo^)_2f4r${#_Qm2U=R(beg(uQqFnh z)!wmL?LTYz-m*8^2_1ZG4okL%OeiRHYe`IS&SN>5@~HE%*2g`)bGVfBb{-4VV&+G%4`(;E#Bm$1~8Q~U}axgP6GkxFAe zo2-;EYw1j{(yZDJre)=BjTZ%2b+Tf)X2kv7^e?1-g}q7tfep+a_r51e70qmWFg;7o`Yg7N6~GTH17CO7)_hAuir>={8g6qxdywYKDcI{_HZF9rD#tb$QwHsEHGA2}o zGfs#*@%Qv;hE;zT{S@8w|1sC?(DdC-E;E*PE}tU2c(0<-q3Wyj2H7>4-xBwKT3Pww z|D%GeJ-?bhuK1;SMLD91e__A+*FYBSGlCylk85YV=xaN+O5pK5sn;CBw}e*qDlNM^ z>ypo}E1Jnu_`(!@)-PV@WU^sJX(q_f53g8wezC^&v7q8xP7CyKXvzKR8Nz`=E2*!!6>ZtPmhF1jXu`&4$d z1BO1$-@g`qNN@|c?(hD;`9{5*W904kj{fRrH>cH$b}kjxE!&oS=5~nFDNW~vVu2G6 zN}UOsWUS3Qz3rHi50mg450=aFywS5+)FS@#xn{^;YuN6{_U4eI0t4gK+DLikwoudd zx$>=&Wt+Trq;ux%@{r?pRhd)Qc}mE$N95K)l?z%M9q;cik$86SEWgyYZSP-9EZzG4 z5BJXgQ->xpW!(&1@B7y|B3AC|`&rA+7Dur3IcqFh`ri62`sHAP3?wfzxOs0K**F$UVu1PoqpG% zXMf#xnva?7OVEP(3J0PVc+7sIec^t_l*y;sl#ALJyM^lcS01mKnD*TF_o8UV7bo*W zBg;R(^m_gEu)>Q=q9L2AFRWR(E&S86wM(`gEt4#HEtay4J2ch7V40(8ch#;LZ_n9g zif>L^e0ouv`r9S9L#F1$+cs^oobP}5<#u8J?x$Yw{aCthM(I72d@=vJSwi>011Gz< zUaiY`<;nfC=2z^G4{|`rEdu))twkp1Up!|ExLR&*;R{Wmo)Xf0lpnZr-F? zad~fD@y**T=WqFYRxd5H{(^a?wpgkD%b9s^e;(SlMBLSkdrR(qeb$$;Z{643nmcXn zKhw!lZb9nnM6S%#{(NKV$>@rmjaId$Lci{bP72+;@~g+AD^opkw@>q}zT%bem6z}2 zg9#Q2hQdJ{U4h3GTn!gkK|GT!dJKS_@ z+|wDRsyEFQ)J-qrY>392adD%aK)9v{z(*o^31$8D#87?uB zQtT+^)Cy^tTytiA_w(=Nb8h`E{%G^>#X|d{`}4Uo?IP-@KdbtEOs~oO_VwG`{f=f5 zDeLBPSNNq)Qc(@lhT%`(HKJ zzNN&(z|e%dk!}LY{-sk+=Up)nX^U4q#U-|~c&_)Bd z86jpCyE*ZwHO~ss6&>G1=a?Tl=VQE3<>ry+-{yag-1e6@RXmjIz*hdsun=S0r+aMN zR&)iOC@gjH)<{uVsB=7KR!--tNsN877uh&(%j^s2^IXVY_t-kM(LdZqnRSlQ+Nh_N zY4ZLGi)YWwd&%Etyve8Xu|s40Bh@*~^|7azG8cuZt67SOE^IxfF!Sa4HIG{_hnPs_ zbL6Z#7Zq%vxK#X9h2;5?;Ik}}E-{`7Kcf%JZ1T}O>3Q$H>sROC(3`RLC+zcoJgrb% zm*BT~yUMedKJ#Bh@o>ha{^`k!J23C0bI#OYQLQfC~GW!adJNY)AfbhUTH`0i0V9jU{ibCpgyE6Wx>%+(K^)* z?ABrrIF7SbY)jrz!NVqZbDrQ%;RE+HHr~m+!fv-jeP8GLTf&9FtV^wYY#{eNk^g0&-`^7Mq zui^1u{u{l~-;|xMSnht6x9?DnXP4J$g^8}uE=J5N*k=94y7}^^ou?I_&Utp$Xlg!- z!BmCEoJ;qZUP;ZKKlgz?YPyPNWxQ~Ok%8d>GtO$o6r8St&*sf`5U3M9ayRlop^$sS z6^nJZoR?|@-u}$g9GP1G;@u6C?JM*zRL@sx4_T%4RrZ5)skn7ZSV7Xiefep2X^-7r zRtGaK_#Rr2DYV~djXtMgC2P|x;n3zSGlMc6FYc;x_F3B~ChJik#ClLtaEhmW@9rDD ze#u*1&#BzWIG@wH;MYU09a%#5U*egz=L9E;D`du8=DovMw?_BCt|iYkXE0>Wm^81= z=;QsC>91Efmx#`nFiA_O&YS78vikWReIfe-W5#wFS=O!dW!E$mA5T&LS)}qcezw(< z3v~C0rm#tD2H3-^2Yt?y;&vatY-0At$QE) z?X17@G9EQ0G%b-icwE>RoI4_XbUT*w$P2!K?Fr#OfF6Tx`2zdX6X>Tz}p6 zyFq7D*xE$KJvqz@JBl8x+`{_YbXUuQb4UMX7)gKc*!RfS`MLia6~4_*e|pXpPwh6z zVq#Wx=MuGxp3!JHQB&jRt;t=@_*_7`^WK~SLKX_mDfehckU4OWZim8VqWrh zmfthi^$W?1tX5KfBh+S6XG=f*EUNOmx6UT+!nPgW#yf4VD*1~^ z@;`OG7-cR4h znypEU3=9XEaAsL^P(+mm-Of8?zyppbiA#D;xl0e4edvVwgCEO2Wr!AbMN3Mtgnm=V zev)FdZ*o|s`ll`Hbq)M$z1^%M5`?0%Mc<_-)J*S`F4WTbx^3A$h0j|8qbt~>OnDxJ z@YyWg_MqvTp5qFmj}^vqeXCsVZ@hM)b+bihn%TYgNuQ$9l4R1Em|0!9jvhBZb8yCq zBa>@nJ&(@MaG7^p{nzt*_Wj!f8K2#&c`@mgk4?vt7O$;s)Am(2E?$0nzu0LZF-XBtM!P(QIp3PZS^_$cU!4Enf*^Y$7bU8 z+KA8M%ssp{@sufJct)B6D-QD!sbj$22CC!<- zX`7XTeO{DozSMk6>$CJ}#wTCm;ltBcj+x!Lw^iDC_S7e9mD@yrI~&Y-`65vId5_X+ z!JK@PZm)A&E`Hcq9&;+)YQ6m1*v919M|%HXYO!73cYay8=`6#uF9I5Gml*!s|NQNd z)rIrf?@!?ScmANozm!VtzJvDL@>ycC*j3A)>R)VeOPyP{TyToHEGUKVup9uX?B}y`*|)!|N|u^^bccLMkJ_y7cz0UiU5B>QrUn+~9bh7j5#D z!M*G5UO(uw=i%wT@Ps|B+a59rq__$=upEjCU`uh8Tl3Za+twqst^!&yc5TyUY(28( zL^Vg~!?vvqtG|e6?9}O zpD(NsvHiJ!uEEmIOV?dF)$%v|WJUGp;|WW|`xzM+_JaDtkX!~Dtxn3%&j#hQ-br`! zZX58l#vi@Fd?s_&l869tjcaAx7yd5Lty+Jg#3w|Ew<~X#-Cq^Sn=ujwWvZVi{HoFT z^vUDTCiN=Kdvm@fo}QZJx+^!5?Q+xQhr*=~lGiDgMJ1hRpEQe&_s)cuvK5-syN@nQ z*$_3MCSxOa)`GwM+9f=@_;edAtQX92Tfa`v|6E;RV4t#(!ApNn-&4ki4F70*B!28- z{GJh57p_$w9nk9B&v$>}x+_ki3zt4NPpVPZzhx(-maScCrZTUQ^{h*skJd3V!dHQ;nT(`e@u=$+r75Z!d?q zaZ7jNndLXHU0Bfm%RQxaO2_Q9N3)hIpD)`PHNUn``fBuV)hzqJwl9CZ-!#YU?<;=P zxLf}8YVBnP28MK21_mie+(FVQatwmg?Aa^Ue1||Q5>~rdGPDJqa@H004nM-W=#ibv zZI_LL1=XvJM7())I(#PPKK*WPX`7V2W25cWUggsFx8@ne8t;~65H&4pOS_h6@8k8_ z`mpm$?Sf@HW^gIaIxqa;y=2w=ed((%AAV@wxlLy_6aTgyWj8*zC+vuoaR ztH9cNO{Kam9>UKMzTfNPpRP_}Pq$WbxO4TdFltbk*lln(W@KRCWoH1- z+DAL*=ar=9mBfcs7Ni!(f|Byt(9?d04Fvv7)wr%zs2QMgBp=m?X9?5d0>U%tt7E3td zFHZ$M4cJkWyhGJh-a#yaal*w+e=AF6Uz?N{`@@@gOH9;7C3kV1Q(i9iplENcPHy?5 znH)?jjoWQJjJ`h*cA8zmcr^cL<)Qa_S7OyPcBTH9vHw}kgZ)=doN^=1JEbtK$<~#TcaEY*Z@$r0ng7N zPt&2Bf<8q9G696QHAZn^H3d9jgKi4?@FU0s5Z=~!gdeLZ;K4|AQ_x2-Kqi3jw#LK4 zSWN+sX`q{e-e(1w0K(fErNywC0`9@0n}Xio0hs{8+Zt6Ru$lty{h*tI-WUg&0K(fE z&q-o61>7`8HwC@v2r>bL8yM51!KNS@lIU8|D}InR5N=?6EQ`_%K-Y*~k%BaVa0BBR zIk26e)CH?n(KVx& list[dict]: @@ -50,30 +66,17 @@ class ForecastService: else: expenses += abs(r.amount) - income += self._asset_income(account) expenses += self._liability_cost(account) balance += income - expenses - asset_growth = sum( - a.value * a.growth_rate / 12 - for a in self.model.assets - ) - balance += asset_growth - monthly.append({ - "balance": balance, - "income": income, - "expenses": expenses, + "balance": round(balance, 2), + "income": round(income, 2), + "expenses": round(expenses, 2), }) return monthly - def _asset_income(self, account: Account) -> float: - return sum( - a.value * a.growth_rate / 12 - for a in self.model.assets - ) - def _liability_cost(self, account: Account) -> float: total = 0.0 for liability in self.model.liabilities: diff --git a/pyproject.toml b/pyproject.toml index 9205743..4df6b29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ include = ["cashflow_model*", "sync*", "engine*", "ai*", "cli*"] [tool.ruff] target-version = "py311" -line-length = 100 +line-length = 120 [tool.ruff.lint] select = ["E", "F", "I", "N", "W"] diff --git a/sync/excel_sync.py b/sync/excel_sync.py index 8525b01..f8ebe05 100644 --- a/sync/excel_sync.py +++ b/sync/excel_sync.py @@ -3,7 +3,15 @@ from uuid import UUID from openpyxl import Workbook, load_workbook -from cashflow_model import Account, Asset, FinancialModel, Liability, RecurringCashflow, Transaction +from cashflow_model import ( + Account, + Asset, + ExchangeRate, + FinancialModel, + Liability, + RecurringCashflow, + Transaction, +) class SyncError(Exception): @@ -31,6 +39,10 @@ _SHEET_CONFIG = { "fields": ["id", "name", "balance", "interest", "payment"], "cls": Liability, }, + "ExchangeRates": { + "fields": ["from_currency", "to_currency", "rate"], + "cls": ExchangeRate, + }, } @@ -42,6 +54,7 @@ class ExcelSync: wb = load_workbook(path, read_only=True, data_only=True) model = FinancialModel() + model.exchange_rates = [] for sheet_name, config in _SHEET_CONFIG.items(): if sheet_name not in wb.sheetnames: @@ -62,6 +75,13 @@ class ExcelSync: data[header] = str(val) if not isinstance(val, (int, float)) else val self._add_to_model(model, sheet_name, data) + if "ModelInfo" in wb.sheetnames: + ws = wb["ModelInfo"] + rows = list(ws.iter_rows(values_only=True)) + for row in rows: + if row[0] and str(row[0]).strip().lower() == "base_currency" and len(row) > 1: + model.base_currency = str(row[1]).strip() + wb.close() return model @@ -76,6 +96,7 @@ class ExcelSync: "Recurring": model.recurring, "Assets": model.assets, "Liabilities": model.liabilities, + "ExchangeRates": model.exchange_rates, } for sheet_name, items in collections.items(): @@ -90,6 +111,10 @@ class ExcelSync: ] ws.append(row) + ws_info = wb.create_sheet(title="ModelInfo") + ws_info.append(["Property", "Value"]) + ws_info.append(["base_currency", model.base_currency]) + wb.save(path) def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None: @@ -104,5 +129,7 @@ class ExcelSync: model.assets.append(Asset.from_dict(data)) elif sheet_name == "Liabilities": model.liabilities.append(Liability.from_dict(data)) + elif sheet_name == "ExchangeRates": + model.exchange_rates.append(ExchangeRate.from_dict(data)) except Exception as e: raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e diff --git a/tests/conftest.py b/tests/conftest.py index 0038e23..cd8fd7a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ import pytest from cashflow_model import ( Account, Asset, + CurrencyConverter, + ExchangeRate, FinancialModel, Liability, RecurringCashflow, @@ -16,9 +18,10 @@ from cashflow_model import ( def sample_model() -> FinancialModel: acc_id = uuid4() return FinancialModel( + base_currency="RUB", accounts=[ - Account(id=acc_id, name="Основной счёт", currency="USD", balance=5000.0), - Account(name="Сбережения", currency="USD", balance=10000.0), + Account(id=acc_id, name="Основной счёт", currency="RUB", balance=5000.0), + Account(name="Сбережения", currency="RUB", balance=10000.0), ], transactions=[ Transaction( @@ -48,9 +51,17 @@ def sample_model() -> FinancialModel: liabilities=[ Liability(name="Кредит", balance=20000.0, interest=5.0, payment=500.0), ], + exchange_rates=[ + ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0), + ], ) @pytest.fixture def empty_model() -> FinancialModel: return FinancialModel() + + +@pytest.fixture +def sample_converter() -> CurrencyConverter: + return CurrencyConverter([ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)]) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c1a63c..47cbf7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,21 +1,35 @@ from typer.testing import CliRunner -from cli.main import app +from cli.main import MODEL_PATH, app runner = CliRunner() +def _cleanup(): + if MODEL_PATH.exists(): + MODEL_PATH.unlink() + + class TestCli: def test_init(self): + _cleanup() result = runner.invoke(app, ["init"]) assert result.exit_code == 0 assert "Пустая модель" in result.stdout def test_forecast_after_init(self): + _cleanup() runner.invoke(app, ["init"]) result = runner.invoke(app, ["forecast", "--months", "3"]) assert result.exit_code == 0 + def test_forecast_with_currency(self): + _cleanup() + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["forecast", "--months", "3", "--currency", "USD"]) + assert result.exit_code == 0 + assert "$" in result.stdout + def test_unknown_scenario(self): result = runner.invoke(app, ["scenario", "unknown"]) assert result.exit_code != 0 @@ -23,3 +37,132 @@ class TestCli: def test_help(self): result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 + + def test_info_empty(self): + _cleanup() + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["info"]) + assert result.exit_code == 0 + assert "Базовая валюта" in result.stdout + + def test_config_base_currency_set(self): + _cleanup() + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["config", "base-currency", "EUR"]) + assert result.exit_code == 0 + assert "EUR" in result.stdout + + info = runner.invoke(app, ["info"]) + assert "EUR" in info.stdout + + def test_config_base_currency_show(self): + _cleanup() + runner.invoke(app, ["init"]) + result = runner.invoke(app, ["config", "base-currency"]) + assert result.exit_code == 0 + assert "RUB" in result.stdout + + def test_config_account_crud(self): + _cleanup() + runner.invoke(app, ["init"]) + + args = ["config", "account-add", "--name", "Тестовый", "--balance", "1000"] + add = runner.invoke(app, args) + assert add.exit_code == 0 + assert "Тестовый" in add.stdout + + lst = runner.invoke(app, ["config", "account-list"]) + assert "Тестовый" in lst.stdout + + remove = runner.invoke(app, ["config", "account-remove", "Тестовый"]) + assert remove.exit_code == 0 + + lst2 = runner.invoke(app, ["config", "account-list"]) + assert "Тестовый" not in lst2.stdout + + def test_config_transaction_crud(self): + _cleanup() + runner.invoke(app, ["init"]) + runner.invoke(app, ["config", "account-add", "--name", "Счёт", "--balance", "0"]) + + add = runner.invoke(app, [ + "config", "transaction-add", + "--account", "Счёт", + "--amount", "5000", + "--category", "income", + "--description", "Зарплата", + ]) + assert add.exit_code == 0 + assert "доход" in add.stdout + + lst = runner.invoke(app, ["config", "transaction-list"]) + assert "Зарплата" in lst.stdout + + def test_config_asset_crud(self): + _cleanup() + runner.invoke(app, ["init"]) + + add = runner.invoke(app, [ + "config", "asset-add", + "--name", "Квартира", + "--value", "5000000", + "--growth", "5", + ]) + assert add.exit_code == 0 + assert "Квартира" in add.stdout + + lst = runner.invoke(app, ["config", "asset-list"]) + assert "Квартира" in lst.stdout + + def test_config_liability_crud(self): + _cleanup() + runner.invoke(app, ["init"]) + + add = runner.invoke(app, [ + "config", "liability-add", + "--name", "Кредит", + "--balance", "100000", + "--interest", "10", + "--payment", "5000", + ]) + assert add.exit_code == 0 + assert "Кредит" in add.stdout + + def test_config_recurring_crud(self): + _cleanup() + runner.invoke(app, ["init"]) + + add = runner.invoke(app, [ + "config", "recurring-add", + "--amount", "-500", + "--category", "аренда", + "--frequency", "monthly", + ]) + assert add.exit_code == 0 + + lst = runner.invoke(app, ["config", "recurring-list"]) + assert "аренда" in lst.stdout + + def test_config_rate_crud(self): + _cleanup() + runner.invoke(app, ["init"]) + + set_r = runner.invoke(app, ["config", "rate-set", "EUR", "RUB", "90"]) + assert set_r.exit_code == 0 + + lst = runner.invoke(app, ["config", "rate-list"]) + assert "EUR" in lst.stdout + assert "90" in lst.stdout + + def test_info_with_data(self): + _cleanup() + runner.invoke(app, ["init"]) + acc_args = ["config", "account-add", "--name", "Основной", "--balance", "50000"] + runner.invoke(app, acc_args) + asset_args = ["config", "asset-add", "--name", "Акции", + "--value", "100000", "--growth", "8.0"] + runner.invoke(app, asset_args) + + result = runner.invoke(app, ["info"]) + assert "Основной" in result.stdout + assert "Акции" in result.stdout diff --git a/tests/test_currency.py b/tests/test_currency.py new file mode 100644 index 0000000..ced7f69 --- /dev/null +++ b/tests/test_currency.py @@ -0,0 +1,72 @@ +import pytest + +from cashflow_model import CurrencyConverter, CurrencyError, ExchangeRate + + +class TestExchangeRate: + def test_to_dict_roundtrip(self): + rate = ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0) + d = rate.to_dict() + r2 = ExchangeRate.from_dict(d) + assert r2.from_currency == "USD" + assert r2.to_currency == "RUB" + assert r2.rate == 80.0 + + def test_defaults(self): + r = ExchangeRate() + assert r.from_currency == "USD" + assert r.to_currency == "RUB" + assert r.rate == 80.0 + + +class TestCurrencyConverter: + def test_convert_usd_to_rub(self, sample_converter): + result = sample_converter.convert(100, "USD", "RUB") + assert result == 8000.0 + + def test_convert_rub_to_usd(self, sample_converter): + result = sample_converter.convert(8000, "RUB", "USD") + assert result == 100.0 + + def test_same_currency(self, sample_converter): + result = sample_converter.convert(500, "USD", "USD") + assert result == 500.0 + + def test_unknown_pair(self): + converter = CurrencyConverter() + with pytest.raises(CurrencyError): + converter.convert(100, "USD", "RUB") + + def test_negative_rate(self): + converter = CurrencyConverter() + with pytest.raises(CurrencyError): + converter.set_rate("USD", "RUB", -1) + + def test_zero_rate(self): + converter = CurrencyConverter() + with pytest.raises(CurrencyError): + converter.set_rate("USD", "RUB", 0) + + def test_inverse_auto(self): + rate = ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0) + converter = CurrencyConverter([rate]) + assert converter.convert(900, "RUB", "EUR") == 10.0 + + def test_get_symbol(self, sample_converter): + assert sample_converter.get_symbol("USD") == "$" + assert sample_converter.get_symbol("RUB") == "₽" + assert sample_converter.get_symbol("XYZ") == "XYZ" + + def test_with_defaults(self): + converter = CurrencyConverter.with_defaults() + assert converter.convert(10, "USD", "RUB") == 800.0 + + def test_multiple_rates(self): + rates = [ + ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0), + ExchangeRate(from_currency="EUR", to_currency="RUB", rate=90.0), + ] + converter = CurrencyConverter(rates) + assert converter.convert(10, "USD", "RUB") == 800.0 + assert converter.convert(10, "EUR", "RUB") == 900.0 + assert converter.convert(900, "RUB", "EUR") == 10.0 diff --git a/tests/test_excel_sync.py b/tests/test_excel_sync.py index 1c01d8b..9cb952c 100644 --- a/tests/test_excel_sync.py +++ b/tests/test_excel_sync.py @@ -22,6 +22,16 @@ class TestExcelSync: assert len(loaded.recurring) == len(sample_model.recurring) assert len(loaded.assets) == len(sample_model.assets) assert len(loaded.liabilities) == len(sample_model.liabilities) + assert len(loaded.exchange_rates) == len(sample_model.exchange_rates) + + def test_roundtrip_preserves_base_currency(self, tmp_path: Path): + sync = ExcelSync() + model = FinancialModel(base_currency="EUR") + p = tmp_path / "eur_model.xlsx" + sync.export_model(model, p) + + loaded = sync.import_model(p) + assert loaded.base_currency == "EUR" def test_import_missing_file(self): sync = ExcelSync() diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..6fcdce8 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,44 @@ +from cli.i18n import Translator, get_lang, set_lang, setup_i18n, t + + +class TestTranslator: + def test_ru_by_default(self): + setup_i18n() + assert get_lang() == "ru" + + def test_ru_returns_russian(self): + tr = Translator("ru") + assert "финансовую" in tr.t("cmd.init.help") + + def test_en_fallback_to_ru(self): + tr = Translator("en") + result = tr.t("cmd.init.help") + assert "финансовую" in result + + def test_unknown_key_returns_key(self): + tr = Translator("ru") + assert tr.t("nonexistent.key") == "nonexistent.key" + + def test_set_lang(self): + setup_i18n() + set_lang("en") + assert get_lang() == "en" + + def test_t_function(self): + setup_i18n() + result = t("cmd.init.ok", path="data/model.json") + assert "data/model.json" in result + assert "Пустая" in result + + def test_global_ok(self): + result = t("global.ok") + assert result == "OK" + + def test_prompt_analyze(self): + result = t("prompt.analyze") + assert "AI-ассистент" in result + + def test_custom_lang_init(self): + tr = Translator("de") + result = tr.t("cmd.init.help") + assert "финансовую" in result # fallback to ru diff --git a/tests/test_model.py b/tests/test_model.py index 0d61fa2..250d826 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -3,6 +3,7 @@ from pathlib import Path from cashflow_model import ( Account, Asset, + ExchangeRate, FinancialModel, ForecastScenario, Liability, @@ -42,6 +43,32 @@ class TestTransaction: class TestFinancialModel: + def test_default_base_currency(self): + model = FinancialModel() + assert model.base_currency == "RUB" + + 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) + assert loaded.base_currency == "EUR" + + def test_base_currency_backward_compat(self, tmp_path: Path): + import json + p = tmp_path / "legacy.json" + with open(p, "w") as f: + json.dump({"accounts": []}, f) + loaded = FinancialModel.load(p) + assert loaded.base_currency == "RUB" + + def test_exchange_rates_default(self): + model = FinancialModel() + 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 + def test_save_load(self, tmp_path: Path): model = FinancialModel() model.accounts.append(Account(name="Test", balance=100.0)) @@ -61,6 +88,8 @@ class TestFinancialModel: d = model.to_dict() assert d["accounts"] == [] assert d["transactions"] == [] + assert d["base_currency"] == "RUB" + assert "exchange_rates" in d def test_all_entities_roundtrip(self, tmp_path: Path): model = FinancialModel( @@ -70,6 +99,7 @@ class TestFinancialModel: assets=[Asset(name="Stock", value=1000.0)], liabilities=[Liability(name="Loan", balance=500.0, interest=5.0, payment=100.0)], scenarios=[ForecastScenario(name="test")], + exchange_rates=[ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)], ) p = tmp_path / "full.json" model.save(p) @@ -80,3 +110,4 @@ class TestFinancialModel: assert len(loaded.assets) == 1 assert len(loaded.liabilities) == 1 assert len(loaded.scenarios) == 1 + assert len(loaded.exchange_rates) == 1