T6: Decompose CLI — main.py → 9 command files, config.py → config_cmd.py

Split infrastructure/cli/:
- main.py (330 LOC) → 9 files in infrastructure/cli/commands/:
  * init.py, forecast.py, scenario.py, whatif.py, compare.py
  * import_xlsx.py, export_xlsx.py, analyze.py, info.py
- config.py (428 LOC) → commands/config_cmd.py (15 sub-commands)
- main.py → app.py (entry point: registers all commands)
- New: paths.py (MODEL_PATH, DATA_DIR)
- New: services.py (composition root + helpers: load_model, save_model,
  build_services, resolve_currency, convert_value, fmt_amount)
- cli/__init__.py (empty) удалён

Each command module: <100 LOC.
pyproject.toml: cf = infrastructure.cli.app:app
tests/test_cli.py: import обновлён на infrastructure.cli.app

CLI behavior идентично, все команды работают.
Tests: 67/67 pass.
This commit is contained in:
2026-10-08 16:56:49 +03:00
parent 541a76835f
commit b6b64a9878
17 changed files with 543 additions and 400 deletions
+45
View File
@@ -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()
+25
View File
@@ -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",
]
+29
View File
@@ -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]")
+48
View File
@@ -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)
@@ -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
@@ -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)
+56
View File
@@ -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)))
@@ -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)
+77
View File
@@ -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)
+17
View File
@@ -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)))
+47
View File
@@ -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)}")
+41
View File
@@ -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)}")
-342
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
"""Пути к данным и экспортам."""
from pathlib import Path
DATA_DIR: Path = Path("data")
MODEL_PATH: Path = DATA_DIR / "model.json"
+60
View File
@@ -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}"
+1 -1
View File
@@ -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*"]
+2 -1
View File
@@ -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()