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 0000000..f9ca80b Binary files /dev/null and b/data/excel.xlsx differ diff --git a/data/model.json b/data/model.json index c6b8eb4..8b8e979 100644 --- a/data/model.json +++ b/data/model.json @@ -1,8 +1,97 @@ { - "accounts": [], - "transactions": [], - "recurring": [], - "assets": [], - "liabilities": [], - "scenarios": [] + "base_currency": "RUB", + "accounts": [ + { + "id": "cfff5fa0-d42d-46d8-81ec-f460234e4123", + "name": "Основной счёт", + "currency": "RUB", + "balance": 2000 + } + ], + "transactions": [ + ], + "recurring": [ + { + "id": "479a9960-d7ec-476b-9621-888ceaa1b671", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": 30000, + "category": "Зарплата" + }, + { + "id": "eb565346-679b-4560-89d1-7b0c80bbcb5a", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -10000, + "category": "Аренда" + }, + { + "id": "30c168a3-cbe0-4d8e-a842-d430d98a80b6", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -3000, + "category": "Коммуналка" + }, + { + "id": "f37ef9d3-76a8-4eb3-9a12-e20817d87079", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -8000, + "category": "Продукты" + }, + { + "id": "f8ef073e-a8ca-4637-bd63-e4faeb1b282f", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -2000, + "category": "Транспорт" + }, + { + "id": "ae8f6ad5-2c66-40d9-b102-64076e9be978", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -500, + "category": "Телефон" + }, + { + "id": "fb1197de-727e-49db-9fb8-c5b2554b0504", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -500, + "category": "Подписки" + }, + { + "id": "d8e3392a-4015-4920-b27c-e72f99e6bbb7", + "start_date": "2026-01-01", + "end_date": "", + "frequency": "monthly", + "amount": -3000, + "category": "Досуг" + } + ], + "assets": [ + { + "id": "bd5080c0-45a9-40b1-9dc4-c1ec41157bd7", + "name": "Вклад в банке", + "value": 35000, + "growth_rate": 12 + } + ], + "liabilities": [ + ], + "scenarios": [], + "exchange_rates": [ + { + "from_currency": "USD", + "to_currency": "RUB", + "rate": 80 + } + ] } \ No newline at end of file diff --git a/engine/forecast.py b/engine/forecast.py index 3b4539b..eea04fe 100644 --- a/engine/forecast.py +++ b/engine/forecast.py @@ -17,17 +17,33 @@ class ForecastService: results = [] for account in self.model.accounts: - balance = account.balance monthly = self._project_account(account, months) for m in range(months): - balance = monthly[m]["balance"] results.append({ "account": account.name, "month": m + 1, - "balance": round(balance, 2), - "income": round(monthly[m]["income"], 2), - "expenses": round(monthly[m]["expenses"], 2), + "balance": monthly[m]["balance"], + "income": monthly[m]["income"], + "expenses": monthly[m]["expenses"], }) + + # Asset growth — once per month, distributed across accounts proportionally + for m in range(months): + total_growth = sum( + a.value * a.growth_rate / 100 / 12 + for a in self.model.assets + ) + month_rows = [r for r in results if r["month"] == m + 1] + total_bal = sum(r["balance"] for r in month_rows) or 1 + for r in month_rows: + share = r["balance"] / total_bal + r["income"] = round(r["income"] + total_growth * share, 2) + r["balance"] = round(r["balance"] + total_growth * share, 2) + + # Compound asset values for next month + for a in self.model.assets: + a.value += a.value * a.growth_rate / 100 / 12 + return results def _project_account(self, account: Account, months: int) -> 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