diff --git a/infrastructure/cli/app.py b/infrastructure/cli/app.py new file mode 100644 index 0000000..78aa209 --- /dev/null +++ b/infrastructure/cli/app.py @@ -0,0 +1,45 @@ +"""Entry point приложения cf. Регистрирует все команды.""" +import sys + +import typer + +try: + sys.stdout.reconfigure(encoding="utf-8") +except (AttributeError, OSError): + pass + +from infrastructure.cli.i18n import setup_i18n, t +from infrastructure.cli.commands import ( + analyze, + compare, + config_cmd, + export_xlsx, + forecast, + import_xlsx, + info, + init, + scenario, + whatif, +) + +setup_i18n() + +app = typer.Typer(name="cf", help=t("app.help")) + +# Команды верхнего уровня +app.command(name="init")(init.init) +app.command(name="forecast")(forecast.forecast) +app.command(name="scenario")(scenario.scenario) +app.command(name="whatif")(whatif.whatif) +app.command(name="compare")(compare.compare) +app.command(name="import-xlsx")(import_xlsx.import_xlsx) +app.command(name="export-xlsx")(export_xlsx.export_xlsx) +app.command(name="analyze")(analyze.analyze) +app.command(name="info")(info.info) + +# Под-приложение config +app.add_typer(config_cmd.app, name="config") + + +if __name__ == "__main__": + app() diff --git a/infrastructure/cli/commands/__init__.py b/infrastructure/cli/commands/__init__.py new file mode 100644 index 0000000..d67082e --- /dev/null +++ b/infrastructure/cli/commands/__init__.py @@ -0,0 +1,25 @@ +from infrastructure.cli.commands import ( + analyze, + compare, + config_cmd, + export_xlsx, + forecast, + import_xlsx, + info, + init, + scenario, + whatif, +) + +__all__ = [ + "analyze", + "compare", + "config_cmd", + "export_xlsx", + "forecast", + "import_xlsx", + "info", + "init", + "scenario", + "whatif", +] diff --git a/infrastructure/cli/commands/analyze.py b/infrastructure/cli/commands/analyze.py new file mode 100644 index 0000000..3de2667 --- /dev/null +++ b/infrastructure/cli/commands/analyze.py @@ -0,0 +1,29 @@ +"""cf analyze — AI-анализ финансовой модели (заглушка).""" +import typer + +from infrastructure.cli.i18n import t +from infrastructure.cli.services import build_services, console, load_model, resolve_currency + +app = typer.Typer() + +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) + + +@app.command() +def analyze( + months: int = typer.Option(12, "--months", "-m", help=t("cmd.analyze.opt.months")), + currency: str | None = CURRENCY_OPTION, +) -> None: + """AI-анализ финансовой модели""" + model = load_model() + target_curr, _ = resolve_currency(model, currency) + _, _, assistant, _ = build_services(model) + assistant.display_currency = target_curr + result = assistant.analyze(months) + + console.print(f"[bold]{t('cmd.analyze.title.prompt')}:[/bold]") + console.print(result["prompt"][:500] + "...\n") + + 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]") diff --git a/infrastructure/cli/commands/compare.py b/infrastructure/cli/commands/compare.py new file mode 100644 index 0000000..33bcbf3 --- /dev/null +++ b/infrastructure/cli/commands/compare.py @@ -0,0 +1,48 @@ +"""cf compare — сравнить все сценарии.""" +import typer +from rich.table import Table + +from infrastructure.cli.i18n import t +from infrastructure.cli.services import ( + build_services, + console, + convert_value, + fmt_amount, + load_model, + resolve_currency, +) + +app = typer.Typer() + +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) + + +@app.command() +def compare( + 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) + _, scenario_service, _, converter = build_services(model) + base_curr = model.base_currency + results = scenario_service.compare(months) + + 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(): + 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) + table.add_row( + name, + fmt_amount(b, target_curr, symbol), + fmt_amount(i, target_curr, symbol), + fmt_amount(e, target_curr, symbol), + ) + console.print(table) diff --git a/infrastructure/cli/config.py b/infrastructure/cli/commands/config_cmd.py similarity index 91% rename from infrastructure/cli/config.py rename to infrastructure/cli/commands/config_cmd.py index 2cc905d..8876878 100644 --- a/infrastructure/cli/config.py +++ b/infrastructure/cli/commands/config_cmd.py @@ -1,5 +1,4 @@ -from pathlib import Path - +"""cf config — подкоманды для редактирования модели (базовая валюта, счета, транзакции, ...).""" import typer from rich.console import Console from rich.table import Table @@ -8,32 +7,16 @@ from domain import ( Account, Asset, ExchangeRate, - FinancialModel, Liability, RecurringCashflow, Transaction, ) from infrastructure.cli.i18n import t -from infrastructure.repositories import JsonFileRepository +from infrastructure.cli.services import load_model, save_model app = typer.Typer(name="config", help=t("config.help")) console = Console() -DATA_DIR = Path("data") -MODEL_PATH = DATA_DIR / "model.json" - -_json_repo = JsonFileRepository() - - -def _load_model() -> FinancialModel: - if MODEL_PATH.exists(): - return _json_repo.load(MODEL_PATH) - return FinancialModel() - - -def _save_model(model: FinancialModel) -> None: - _json_repo.save(model, MODEL_PATH) - # ── base-currency ────────────────────────────────────────────── @@ -43,10 +26,10 @@ def base_currency( currency: str = typer.Argument(None, help=t("cmd.config.base_currency.arg")), ) -> None: """Показать или установить базовую валюту модели""" - model = _load_model() + model = load_model() if currency: model.base_currency = currency.upper() - _save_model(model) + 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}") @@ -64,26 +47,25 @@ def rate_set( rate: float = typer.Argument(..., help=t("cmd.config.rate_set.arg.rate")), ) -> None: """Добавить или обновить курс валюты""" - model = _load_model() + 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(), + from_currency=fc, + to_currency=tc, rate=rate, )) - _save_model(model) - fc, tc = from_currency.upper(), to_currency.upper() + save_model(model) 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() + model = load_model() if not model.exchange_rates: console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]") return @@ -102,7 +84,7 @@ def rate_remove( to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")), ) -> None: """Удалить курс валюты""" - model = _load_model() + model = load_model() fc, tc = from_currency.upper(), to_currency.upper() initial = len(model.exchange_rates) model.exchange_rates = [ @@ -110,7 +92,7 @@ def rate_remove( if not (r.from_currency == fc and r.to_currency == tc) ] if len(model.exchange_rates) < initial: - _save_model(model) + 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]") @@ -127,11 +109,11 @@ def account_add( currency: str = typer.Option("", "--currency", "-c", help=t("cmd.config.account_add.opt.currency")), ) -> None: """Добавить счёт""" - model = _load_model() + 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) + 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}") @@ -140,7 +122,7 @@ def account_add( @app.command(name="account-list") def account_list() -> None: """Список счетов""" - model = _load_model() + model = load_model() if not model.accounts: console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]") return @@ -159,14 +141,14 @@ def account_remove( identifier: str = typer.Argument(..., help=t("cmd.config.account_remove.arg")), ) -> None: """Удалить счёт по ID или названию""" - model = _load_model() + 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) + 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]") @@ -185,7 +167,7 @@ def transaction_add( date: str = typer.Option("", "--date", "-D", help=t("cmd.config.transaction_add.opt.date")), ) -> None: """Добавить транзакцию""" - model = _load_model() + 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]") @@ -198,7 +180,7 @@ def transaction_add( description=description, ) model.transactions.append(txn) - _save_model(model) + 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) @@ -208,7 +190,7 @@ def transaction_add( @app.command(name="transaction-list") def transaction_list() -> None: """Список транзакций""" - model = _load_model() + model = load_model() if not model.transactions: console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]") return @@ -233,11 +215,11 @@ def transaction_remove( identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")), ) -> None: """Удалить транзакцию по ID""" - model = _load_model() + 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) + 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]") @@ -256,13 +238,13 @@ def recurring_add( end_date: str = typer.Option("", "--end", "-e", help=t("cmd.config.recurring_add.opt.end")), ) -> None: """Добавить регулярный платёж""" - model = _load_model() + 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) + 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}") @@ -271,7 +253,7 @@ def recurring_add( @app.command(name="recurring-list") def recurring_list() -> None: """Список регулярных платежей""" - model = _load_model() + model = load_model() if not model.recurring: console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]") return @@ -293,11 +275,11 @@ def recurring_remove( identifier: str = typer.Argument(..., help=t("cmd.config.recurring_remove.arg")), ) -> None: """Удалить регулярный платёж по ID""" - model = _load_model() + 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) + 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]") @@ -314,17 +296,17 @@ def asset_add( growth_rate: float = typer.Option(0.0, "--growth", "-g", help=t("cmd.config.asset_add.opt.growth")), ) -> None: """Добавить актив""" - model = _load_model() + model = load_model() asset = Asset(name=name, value=value, growth_rate=growth_rate) model.assets.append(asset) - _save_model(model) + 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() + model = load_model() if not model.assets: console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]") return @@ -343,14 +325,14 @@ def asset_remove( identifier: str = typer.Argument(..., help=t("cmd.config.asset_remove.arg")), ) -> None: """Удалить актив по ID или названию""" - model = _load_model() + 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) + 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]") @@ -368,17 +350,17 @@ def liability_add( payment: float = typer.Option(0.0, "--payment", "-p", help=t("cmd.config.liability_add.opt.payment")), ) -> None: """Добавить обязательство""" - model = _load_model() + model = load_model() liability = Liability(name=name, balance=balance, interest=interest, payment=payment) model.liabilities.append(liability) - _save_model(model) + 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() + model = load_model() if not model.liabilities: console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]") return @@ -400,14 +382,14 @@ def liability_remove( identifier: str = typer.Argument(..., help=t("cmd.config.liability_remove.arg")), ) -> None: """Удалить обязательство по ID или названию""" - model = _load_model() + 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) + 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]") @@ -417,14 +399,14 @@ def liability_remove( # ── helpers ──────────────────────────────────────────────────── -def _resolve_account_id(model: FinancialModel, identifier: str) -> str | None: +def _resolve_account_id(model, 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: +def _account_name(model, account_id: str) -> str: for a in model.accounts: if str(a.id) == account_id: return a.name diff --git a/infrastructure/cli/commands/export_xlsx.py b/infrastructure/cli/commands/export_xlsx.py new file mode 100644 index 0000000..c2e9901 --- /dev/null +++ b/infrastructure/cli/commands/export_xlsx.py @@ -0,0 +1,23 @@ +"""cf export-xlsx — экспорт модели в Excel.""" +import typer + +from infrastructure.cli.i18n import t +from infrastructure.cli.services import console, load_model +from infrastructure.repositories import ExcelRepository, ExcelRepositoryError + +app = typer.Typer() +_repo = ExcelRepository() + + +@app.command(name="export-xlsx") +def export_xlsx( + path: str = typer.Argument("exports/forecast.xlsx", help=t("cmd.export.arg.path")), +) -> None: + """Экспорт модели в Excel""" + model = load_model() + try: + _repo.save(model, path) + console.print(t("cmd.export.ok", path=path)) + except ExcelRepositoryError as e: + console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]") + raise typer.Exit(1) diff --git a/infrastructure/cli/commands/forecast.py b/infrastructure/cli/commands/forecast.py new file mode 100644 index 0000000..ff02432 --- /dev/null +++ b/infrastructure/cli/commands/forecast.py @@ -0,0 +1,56 @@ +"""cf forecast — прогноз денежных потоков.""" +import typer +from rich.table import Table + +from infrastructure.cli.i18n import t +from infrastructure.cli.services import ( + build_services, + console, + convert_value, + fmt_amount, + load_model, + resolve_currency, +) + +app = typer.Typer() + +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) + + +@app.command() +def forecast( + 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) + forecast_service, _, _, converter = build_services(model) + base_curr = model.base_currency + results = forecast_service.forecast_cashflow(months) + summary = forecast_service.summary(months) + + if results: + 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"]), + fmt_amount(convert_value(converter, r["balance"], base_curr, target_curr), target_curr, symbol), + fmt_amount(convert_value(converter, r["income"], base_curr, target_curr), target_curr, symbol), + fmt_amount(convert_value(converter, r["expenses"], base_curr, target_curr), target_curr, symbol), + ) + console.print(table) + + cb = convert_value(converter, summary["total_balance"], base_curr, target_curr) + ci = convert_value(converter, summary["total_income"], base_curr, target_curr) + ce = convert_value(converter, summary["total_expenses"], base_curr, target_curr) + console.print(t("cmd.forecast.total", + balance=fmt_amount(cb, target_curr, symbol), + income=fmt_amount(ci, target_curr, symbol), + expenses=fmt_amount(ce, target_curr, symbol))) diff --git a/infrastructure/cli/commands/import_xlsx.py b/infrastructure/cli/commands/import_xlsx.py new file mode 100644 index 0000000..5415ec8 --- /dev/null +++ b/infrastructure/cli/commands/import_xlsx.py @@ -0,0 +1,29 @@ +"""cf import-xlsx — импорт данных из Excel.""" +import typer + +from infrastructure.cli.i18n import t +from infrastructure.cli.services import console, save_model +from infrastructure.repositories import ExcelRepository, ExcelRepositoryError + +app = typer.Typer() +_repo = ExcelRepository() + + +@app.command(name="import-xlsx") +def import_xlsx( + path: str = typer.Argument(..., help=t("cmd.import.arg.path")), +) -> None: + """Импорт данных из Excel""" + try: + model = _repo.load(path) + save_model(model) + 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 ExcelRepositoryError as e: + console.print(f"[red]{t('cmd.import.err', error=str(e))}[/red]") + raise typer.Exit(1) diff --git a/infrastructure/cli/commands/info.py b/infrastructure/cli/commands/info.py new file mode 100644 index 0000000..38bd083 --- /dev/null +++ b/infrastructure/cli/commands/info.py @@ -0,0 +1,77 @@ +"""cf info — сводка всей финансовой модели.""" +import typer +from rich.table import Table + +from domain import CurrencyConverter +from infrastructure.cli.i18n import t +from infrastructure.cli.services import console, load_model + +app = typer.Typer() + + +@app.command() +def info() -> None: + """Сводка всей финансовой модели""" + model = load_model() + converter = CurrencyConverter(model.exchange_rates) + 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) diff --git a/infrastructure/cli/commands/init.py b/infrastructure/cli/commands/init.py new file mode 100644 index 0000000..9e0cf96 --- /dev/null +++ b/infrastructure/cli/commands/init.py @@ -0,0 +1,17 @@ +"""cf init — создать пустую модель.""" +import typer + +from infrastructure.cli.i18n import t +from infrastructure.cli.paths import MODEL_PATH +from infrastructure.cli.services import console, save_model +from domain import FinancialModel + +app = typer.Typer() + + +@app.command() +def init() -> None: + """Создать пустую финансовую модель""" + model = FinancialModel() + save_model(model) + console.print(t("cmd.init.ok", path=str(MODEL_PATH))) diff --git a/infrastructure/cli/commands/scenario.py b/infrastructure/cli/commands/scenario.py new file mode 100644 index 0000000..5626aa5 --- /dev/null +++ b/infrastructure/cli/commands/scenario.py @@ -0,0 +1,47 @@ +"""cf scenario — применить именованный сценарий (baseline/optimistic/pessimistic).""" +import typer + +from application.scenarios import DEFAULT_SCENARIOS +from infrastructure.cli.i18n import t +from infrastructure.cli.services import ( + build_services, + console, + convert_value, + fmt_amount, + load_model, + resolve_currency, +) + +app = typer.Typer() + +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) + + +@app.command() +def scenario( + 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) + _, scenario_service, _, converter = build_services(model) + base_curr = model.base_currency + + if name in DEFAULT_SCENARIOS: + scenario_obj = DEFAULT_SCENARIOS[name] + else: + 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 = scenario_service.apply(scenario_obj, months) + 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"{t('cmd.scenario.label.balance')}: {fmt_amount(balance, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.income')}: {fmt_amount(income, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.expenses')}: {fmt_amount(expenses, target_curr, symbol)}") diff --git a/infrastructure/cli/commands/whatif.py b/infrastructure/cli/commands/whatif.py new file mode 100644 index 0000000..9011fd4 --- /dev/null +++ b/infrastructure/cli/commands/whatif.py @@ -0,0 +1,41 @@ +"""cf whatif — what-if анализ с произвольными множителями.""" +import typer + +from infrastructure.cli.i18n import t +from infrastructure.cli.services import ( + build_services, + console, + convert_value, + fmt_amount, + load_model, + resolve_currency, +) + +app = typer.Typer() + +CURRENCY_OPTION = typer.Option(None, "--currency", "-c", help=t("cmd.forecast.opt.currency")) + + +@app.command() +def whatif( + income: float = typer.Option(1.0, "--income", "-i", help=t("cmd.whatif.opt.income")), + expense: float = typer.Option(1.0, "--expense", "-e", help=t("cmd.whatif.opt.expense")), + growth: 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) + _, scenario_service, _, converter = build_services(model) + base_curr = model.base_currency + result = scenario_service.what_if(income, expense, growth, months) + + balance = convert_value(converter, result["total_balance"], base_curr, target_curr) + income_v = 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, expense=expense, growth=growth)) + console.print(f"{t('cmd.scenario.label.balance')}: {fmt_amount(balance, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.income')}: {fmt_amount(income_v, target_curr, symbol)}") + console.print(f"{t('cmd.scenario.label.expenses')}: {fmt_amount(expenses, target_curr, symbol)}") diff --git a/infrastructure/cli/main.py b/infrastructure/cli/main.py deleted file mode 100644 index 2ad01c6..0000000 --- a/infrastructure/cli/main.py +++ /dev/null @@ -1,342 +0,0 @@ -import sys -from pathlib import Path - -import typer -from rich.console import Console -from rich.table import Table - -from infrastructure.ai.assistant import AssistantService -from domain import CurrencyConverter, FinancialModel -from infrastructure.cli.config import app as config_app -from infrastructure.cli.i18n import setup_i18n, t -from application.forecast import ForecastService -from application.scenarios import DEFAULT_SCENARIOS, ScenarioService -from infrastructure.repositories import ExcelRepository, JsonFileRepository - -try: - sys.stdout.reconfigure(encoding="utf-8") -except (AttributeError, OSError): - pass - -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")) - -_json_repo = JsonFileRepository() -_excel_repo = ExcelRepository() - - -def _load_model() -> FinancialModel: - if MODEL_PATH.exists(): - return _json_repo.load(MODEL_PATH) - return FinancialModel() - - -def _save_model(model: FinancialModel) -> None: - _json_repo.save(model, MODEL_PATH) - - -def _build_services(model: FinancialModel): - """Composition root: собрать граф сервисов с зависимостями.""" - from application.forecast import ForecastService - from application.scenarios import ScenarioService - from infrastructure.ai.assistant import AssistantService - - converter = CurrencyConverter(model.exchange_rates) - forecast_service = ForecastService(model) - scenario_service = ScenarioService(model) - assistant_service = AssistantService( - model, forecast_service=forecast_service, converter=converter - ) - return forecast_service, scenario_service, assistant_service, converter - - -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(t("cmd.init.ok", path=str(MODEL_PATH))) - - -@app.command() -def forecast( - 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) - forecast_service, _, _, converter = _build_services(model) - base_curr = model.base_currency - results = forecast_service.forecast_cashflow(months) - summary = forecast_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=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(_cv(r["balance"])), - _f(_cv(r["income"])), - _f(_cv(r["expenses"])), - ) - console.print(table) - - 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=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) - _, scenario_service, _, converter = _build_services(model) - base_curr = model.base_currency - - if name in DEFAULT_SCENARIOS: - scenario_obj = DEFAULT_SCENARIOS[name] - else: - 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 = scenario_service.apply(scenario_obj, months) - 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"{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=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) - _, scenario_service, _, converter = _build_services(model) - base_curr = model.base_currency - result = scenario_service.what_if(income_mult, expense_mult, growth_mult, months) - - 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=t("cmd.compare.opt.months")), - currency: str | None = CURRENCY_OPTION, -) -> None: - """Сравнить все сценарии""" - model = _load_model() - target_curr, symbol = _resolve_currency(model, currency) - _, scenario_service, _, converter = _build_services(model) - base_curr = model.base_currency - results = scenario_service.compare(months) - - 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(): - 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=t("cmd.import.arg.path")), -) -> None: - """Импорт данных из Excel""" - try: - model = _excel_repo.load(path) - _save_model(model) - 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]{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=t("cmd.export.arg.path")), -) -> None: - """Экспорт модели в Excel""" - model = _load_model() - try: - _excel_repo.save(model, path) - console.print(t("cmd.export.ok", path=path)) - except Exception as e: - console.print(f"[red]{t('cmd.export.err', error=str(e))}[/red]") - raise typer.Exit(1) - - -@app.command() -def analyze( - months: int = typer.Option(12, "--months", "-m", help=t("cmd.analyze.opt.months")), - currency: str | None = CURRENCY_OPTION, -) -> None: - """AI-анализ финансовой модели""" - model = _load_model() - target_curr, _ = _resolve_currency(model, currency) - _, _, assistant, _ = _build_services(model) - assistant.display_currency = target_curr - result = assistant.analyze(months) - - console.print(f"[bold]{t('cmd.analyze.title.prompt')}:[/bold]") - console.print(result["prompt"][:500] + "...\n") - - 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/infrastructure/cli/paths.py b/infrastructure/cli/paths.py new file mode 100644 index 0000000..d172ee9 --- /dev/null +++ b/infrastructure/cli/paths.py @@ -0,0 +1,5 @@ +"""Пути к данным и экспортам.""" +from pathlib import Path + +DATA_DIR: Path = Path("data") +MODEL_PATH: Path = DATA_DIR / "model.json" diff --git a/infrastructure/cli/services.py b/infrastructure/cli/services.py new file mode 100644 index 0000000..6c61dac --- /dev/null +++ b/infrastructure/cli/services.py @@ -0,0 +1,60 @@ +"""Composition root + общие хелперы для CLI-команд.""" +from __future__ import annotations + +from decimal import Decimal + +from rich.console import Console + +from application.forecast import ForecastService +from application.scenarios import ScenarioService +from domain import CurrencyConverter, FinancialModel +from infrastructure.ai.assistant import AssistantService +from infrastructure.cli.paths import MODEL_PATH +from infrastructure.repositories import JsonFileRepository + + +console = Console() + +_json_repo = JsonFileRepository() + + +def load_model() -> FinancialModel: + if MODEL_PATH.exists(): + return _json_repo.load(MODEL_PATH) + return FinancialModel() + + +def save_model(model: FinancialModel) -> None: + _json_repo.save(model, MODEL_PATH) + + +def build_services(model: FinancialModel) -> tuple[ForecastService, ScenarioService, AssistantService, CurrencyConverter]: + """Composition root: собрать граф сервисов с зависимостями.""" + converter = CurrencyConverter(model.exchange_rates) + forecast_service = ForecastService(model) + scenario_service = ScenarioService(model) + assistant_service = AssistantService( + model, forecast_service=forecast_service, converter=converter + ) + return forecast_service, scenario_service, assistant_service, converter + + +def resolve_currency(model: FinancialModel, currency: str | None) -> tuple[str, str]: + target = currency or model.base_currency + converter = CurrencyConverter(model.exchange_rates) + symbol = converter.get_symbol(target) + return target, symbol + + +def convert_value( + converter: CurrencyConverter, amount: Decimal, from_curr: str, to_curr: str +) -> Decimal: + if to_curr == from_curr: + return amount + return converter.convert(amount, from_curr, to_curr) + + +def fmt_amount(amount: Decimal | float, currency: str, symbol: str) -> str: + if isinstance(amount, Decimal): + return f"{symbol}{amount:,.2f}" + return f"{symbol}{amount:,.2f}" diff --git a/pyproject.toml b/pyproject.toml index a2a9c9e..5df8a41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ ] [project.scripts] -cf = "infrastructure.cli.main:app" +cf = "infrastructure.cli.app:app" [tool.setuptools.packages.find] include = ["domain*", "application*", "infrastructure*"] diff --git a/tests/test_cli.py b/tests/test_cli.py index db5cb06..beec93a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from typer.testing import CliRunner -from infrastructure.cli.main import MODEL_PATH, app +from infrastructure.cli.app import app +from infrastructure.cli.paths import MODEL_PATH runner = CliRunner()