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
+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)
+413
View File
@@ -0,0 +1,413 @@
"""cf config — подкоманды для редактирования модели (базовая валюта, счета, транзакции, ...)."""
import typer
from rich.console import Console
from rich.table import Table
from domain import (
Account,
Asset,
ExchangeRate,
Liability,
RecurringCashflow,
Transaction,
)
from infrastructure.cli.i18n import t
from infrastructure.cli.services import load_model, save_model
app = typer.Typer(name="config", help=t("config.help"))
console = Console()
# ── 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=fc,
to_currency=tc,
rate=rate,
))
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()
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, 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, account_id: str) -> str:
for a in model.accounts:
if str(a.id) == account_id:
return a.name
return account_id[:8]
@@ -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)}")