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:
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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]")
|
||||||
@@ -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
|
import typer
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
@@ -8,32 +7,16 @@ from domain import (
|
|||||||
Account,
|
Account,
|
||||||
Asset,
|
Asset,
|
||||||
ExchangeRate,
|
ExchangeRate,
|
||||||
FinancialModel,
|
|
||||||
Liability,
|
Liability,
|
||||||
RecurringCashflow,
|
RecurringCashflow,
|
||||||
Transaction,
|
Transaction,
|
||||||
)
|
)
|
||||||
from infrastructure.cli.i18n import t
|
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"))
|
app = typer.Typer(name="config", help=t("config.help"))
|
||||||
console = Console()
|
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 ──────────────────────────────────────────────
|
# ── base-currency ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -43,10 +26,10 @@ def base_currency(
|
|||||||
currency: str = typer.Argument(None, help=t("cmd.config.base_currency.arg")),
|
currency: str = typer.Argument(None, help=t("cmd.config.base_currency.arg")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Показать или установить базовую валюту модели"""
|
"""Показать или установить базовую валюту модели"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if currency:
|
if currency:
|
||||||
model.base_currency = currency.upper()
|
model.base_currency = currency.upper()
|
||||||
_save_model(model)
|
save_model(model)
|
||||||
ok = t("global.ok")
|
ok = t("global.ok")
|
||||||
msg = t("cmd.config.base_currency.ok", currency=model.base_currency)
|
msg = t("cmd.config.base_currency.ok", currency=model.base_currency)
|
||||||
console.print(f"[green]{ok}[/green] {msg}")
|
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")),
|
rate: float = typer.Argument(..., help=t("cmd.config.rate_set.arg.rate")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Добавить или обновить курс валюты"""
|
"""Добавить или обновить курс валюты"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
fc, tc = from_currency.upper(), to_currency.upper()
|
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]
|
existing = [r for r in model.exchange_rates if r.from_currency == fc and r.to_currency == tc]
|
||||||
if existing:
|
if existing:
|
||||||
existing[0].rate = rate
|
existing[0].rate = rate
|
||||||
else:
|
else:
|
||||||
model.exchange_rates.append(ExchangeRate(
|
model.exchange_rates.append(ExchangeRate(
|
||||||
from_currency=from_currency.upper(),
|
from_currency=fc,
|
||||||
to_currency=to_currency.upper(),
|
to_currency=tc,
|
||||||
rate=rate,
|
rate=rate,
|
||||||
))
|
))
|
||||||
_save_model(model)
|
save_model(model)
|
||||||
fc, tc = from_currency.upper(), to_currency.upper()
|
|
||||||
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_set.ok', fc=fc, tc=tc, rate=rate)}")
|
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")
|
@app.command(name="rate-list")
|
||||||
def rate_list() -> None:
|
def rate_list() -> None:
|
||||||
"""Список курсов валют"""
|
"""Список курсов валют"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if not model.exchange_rates:
|
if not model.exchange_rates:
|
||||||
console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]")
|
console.print(f"[yellow]{t('cmd.config.rate_list.empty')}[/yellow]")
|
||||||
return
|
return
|
||||||
@@ -102,7 +84,7 @@ def rate_remove(
|
|||||||
to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")),
|
to_currency: str = typer.Argument(..., help=t("cmd.config.rate_set.arg.to")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Удалить курс валюты"""
|
"""Удалить курс валюты"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
fc, tc = from_currency.upper(), to_currency.upper()
|
fc, tc = from_currency.upper(), to_currency.upper()
|
||||||
initial = len(model.exchange_rates)
|
initial = len(model.exchange_rates)
|
||||||
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 not (r.from_currency == fc and r.to_currency == tc)
|
||||||
]
|
]
|
||||||
if len(model.exchange_rates) < initial:
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.rate_remove.ok', fc=fc, tc=tc)}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]{t('cmd.config.rate_remove.err', fc=fc, tc=tc)}[/red]")
|
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")),
|
currency: str = typer.Option("", "--currency", "-c", help=t("cmd.config.account_add.opt.currency")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Добавить счёт"""
|
"""Добавить счёт"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
curr = currency.upper() if currency else model.base_currency
|
curr = currency.upper() if currency else model.base_currency
|
||||||
account = Account(name=name, balance=balance, currency=curr)
|
account = Account(name=name, balance=balance, currency=curr)
|
||||||
model.accounts.append(account)
|
model.accounts.append(account)
|
||||||
_save_model(model)
|
save_model(model)
|
||||||
ok = t("global.ok")
|
ok = t("global.ok")
|
||||||
msg = t("cmd.config.account_add.ok", name=name, balance=balance, currency=curr)
|
msg = t("cmd.config.account_add.ok", name=name, balance=balance, currency=curr)
|
||||||
console.print(f"[green]{ok}[/green] {msg}")
|
console.print(f"[green]{ok}[/green] {msg}")
|
||||||
@@ -140,7 +122,7 @@ def account_add(
|
|||||||
@app.command(name="account-list")
|
@app.command(name="account-list")
|
||||||
def account_list() -> None:
|
def account_list() -> None:
|
||||||
"""Список счетов"""
|
"""Список счетов"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if not model.accounts:
|
if not model.accounts:
|
||||||
console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]")
|
console.print(f"[yellow]{t('cmd.config.account_list.empty')}[/yellow]")
|
||||||
return
|
return
|
||||||
@@ -159,14 +141,14 @@ def account_remove(
|
|||||||
identifier: str = typer.Argument(..., help=t("cmd.config.account_remove.arg")),
|
identifier: str = typer.Argument(..., help=t("cmd.config.account_remove.arg")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Удалить счёт по ID или названию"""
|
"""Удалить счёт по ID или названию"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
initial = len(model.accounts)
|
initial = len(model.accounts)
|
||||||
model.accounts = [
|
model.accounts = [
|
||||||
a for a in model.accounts
|
a for a in model.accounts
|
||||||
if str(a.id) != identifier and a.name != identifier
|
if str(a.id) != identifier and a.name != identifier
|
||||||
]
|
]
|
||||||
if len(model.accounts) < initial:
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.account_remove.ok', id=identifier)}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]{t('cmd.config.account_remove.err', id=identifier)}[/red]")
|
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")),
|
date: str = typer.Option("", "--date", "-D", help=t("cmd.config.transaction_add.opt.date")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Добавить транзакцию"""
|
"""Добавить транзакцию"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
account_id = _resolve_account_id(model, account)
|
account_id = _resolve_account_id(model, account)
|
||||||
if not account_id:
|
if not account_id:
|
||||||
console.print(f"[red]{t('cmd.config.transaction_add.err', account=account)}[/red]")
|
console.print(f"[red]{t('cmd.config.transaction_add.err', account=account)}[/red]")
|
||||||
@@ -198,7 +180,7 @@ def transaction_add(
|
|||||||
description=description,
|
description=description,
|
||||||
)
|
)
|
||||||
model.transactions.append(txn)
|
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")
|
kind = t("cmd.config.transaction_add.kind.income") if amount >= 0 else t("cmd.config.transaction_add.kind.expense")
|
||||||
ok = t("global.ok")
|
ok = t("global.ok")
|
||||||
msg = t("cmd.config.transaction_add.ok", kind=kind, amount=abs(amount), account=account)
|
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")
|
@app.command(name="transaction-list")
|
||||||
def transaction_list() -> None:
|
def transaction_list() -> None:
|
||||||
"""Список транзакций"""
|
"""Список транзакций"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if not model.transactions:
|
if not model.transactions:
|
||||||
console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]")
|
console.print(f"[yellow]{t('cmd.config.transaction_list.empty')}[/yellow]")
|
||||||
return
|
return
|
||||||
@@ -233,11 +215,11 @@ def transaction_remove(
|
|||||||
identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")),
|
identifier: str = typer.Argument(..., help=t("cmd.config.transaction_remove.arg")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Удалить транзакцию по ID"""
|
"""Удалить транзакцию по ID"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
initial = len(model.transactions)
|
initial = len(model.transactions)
|
||||||
model.transactions = [t for t in model.transactions if str(t.id) != identifier]
|
model.transactions = [t for t in model.transactions if str(t.id) != identifier]
|
||||||
if len(model.transactions) < initial:
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.transaction_remove.ok', id=identifier)}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]{t('cmd.config.transaction_remove.err', id=identifier)}[/red]")
|
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")),
|
end_date: str = typer.Option("", "--end", "-e", help=t("cmd.config.recurring_add.opt.end")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Добавить регулярный платёж"""
|
"""Добавить регулярный платёж"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
rc = RecurringCashflow(
|
rc = RecurringCashflow(
|
||||||
start_date=start_date or "", end_date=end_date or "",
|
start_date=start_date or "", end_date=end_date or "",
|
||||||
frequency=frequency, amount=amount, category=category,
|
frequency=frequency, amount=amount, category=category,
|
||||||
)
|
)
|
||||||
model.recurring.append(rc)
|
model.recurring.append(rc)
|
||||||
_save_model(model)
|
save_model(model)
|
||||||
ok = t("global.ok")
|
ok = t("global.ok")
|
||||||
msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency)
|
msg = t("cmd.config.recurring_add.ok", amount=abs(amount), frequency=frequency)
|
||||||
console.print(f"[green]{ok}[/green] {msg}")
|
console.print(f"[green]{ok}[/green] {msg}")
|
||||||
@@ -271,7 +253,7 @@ def recurring_add(
|
|||||||
@app.command(name="recurring-list")
|
@app.command(name="recurring-list")
|
||||||
def recurring_list() -> None:
|
def recurring_list() -> None:
|
||||||
"""Список регулярных платежей"""
|
"""Список регулярных платежей"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if not model.recurring:
|
if not model.recurring:
|
||||||
console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]")
|
console.print(f"[yellow]{t('cmd.config.recurring_list.empty')}[/yellow]")
|
||||||
return
|
return
|
||||||
@@ -293,11 +275,11 @@ def recurring_remove(
|
|||||||
identifier: str = typer.Argument(..., help=t("cmd.config.recurring_remove.arg")),
|
identifier: str = typer.Argument(..., help=t("cmd.config.recurring_remove.arg")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Удалить регулярный платёж по ID"""
|
"""Удалить регулярный платёж по ID"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
initial = len(model.recurring)
|
initial = len(model.recurring)
|
||||||
model.recurring = [r for r in model.recurring if str(r.id) != identifier]
|
model.recurring = [r for r in model.recurring if str(r.id) != identifier]
|
||||||
if len(model.recurring) < initial:
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.recurring_remove.ok', id=identifier)}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]{t('cmd.config.recurring_remove.err', id=identifier)}[/red]")
|
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")),
|
growth_rate: float = typer.Option(0.0, "--growth", "-g", help=t("cmd.config.asset_add.opt.growth")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Добавить актив"""
|
"""Добавить актив"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
asset = Asset(name=name, value=value, growth_rate=growth_rate)
|
asset = Asset(name=name, value=value, growth_rate=growth_rate)
|
||||||
model.assets.append(asset)
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_add.ok', name=name, value=value)}")
|
||||||
|
|
||||||
|
|
||||||
@app.command(name="asset-list")
|
@app.command(name="asset-list")
|
||||||
def asset_list() -> None:
|
def asset_list() -> None:
|
||||||
"""Список активов"""
|
"""Список активов"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if not model.assets:
|
if not model.assets:
|
||||||
console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]")
|
console.print(f"[yellow]{t('cmd.config.asset_list.empty')}[/yellow]")
|
||||||
return
|
return
|
||||||
@@ -343,14 +325,14 @@ def asset_remove(
|
|||||||
identifier: str = typer.Argument(..., help=t("cmd.config.asset_remove.arg")),
|
identifier: str = typer.Argument(..., help=t("cmd.config.asset_remove.arg")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Удалить актив по ID или названию"""
|
"""Удалить актив по ID или названию"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
initial = len(model.assets)
|
initial = len(model.assets)
|
||||||
model.assets = [
|
model.assets = [
|
||||||
a for a in model.assets
|
a for a in model.assets
|
||||||
if str(a.id) != identifier and a.name != identifier
|
if str(a.id) != identifier and a.name != identifier
|
||||||
]
|
]
|
||||||
if len(model.assets) < initial:
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.asset_remove.ok', id=identifier)}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]{t('cmd.config.asset_remove.err', id=identifier)}[/red]")
|
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")),
|
payment: float = typer.Option(0.0, "--payment", "-p", help=t("cmd.config.liability_add.opt.payment")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Добавить обязательство"""
|
"""Добавить обязательство"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
liability = Liability(name=name, balance=balance, interest=interest, payment=payment)
|
liability = Liability(name=name, balance=balance, interest=interest, payment=payment)
|
||||||
model.liabilities.append(liability)
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_add.ok', name=name, balance=balance)}")
|
||||||
|
|
||||||
|
|
||||||
@app.command(name="liability-list")
|
@app.command(name="liability-list")
|
||||||
def liability_list() -> None:
|
def liability_list() -> None:
|
||||||
"""Список обязательств"""
|
"""Список обязательств"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
if not model.liabilities:
|
if not model.liabilities:
|
||||||
console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]")
|
console.print(f"[yellow]{t('cmd.config.liability_list.empty')}[/yellow]")
|
||||||
return
|
return
|
||||||
@@ -400,14 +382,14 @@ def liability_remove(
|
|||||||
identifier: str = typer.Argument(..., help=t("cmd.config.liability_remove.arg")),
|
identifier: str = typer.Argument(..., help=t("cmd.config.liability_remove.arg")),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Удалить обязательство по ID или названию"""
|
"""Удалить обязательство по ID или названию"""
|
||||||
model = _load_model()
|
model = load_model()
|
||||||
initial = len(model.liabilities)
|
initial = len(model.liabilities)
|
||||||
model.liabilities = [
|
model.liabilities = [
|
||||||
li for li in model.liabilities
|
li for li in model.liabilities
|
||||||
if str(li.id) != identifier and li.name != identifier
|
if str(li.id) != identifier and li.name != identifier
|
||||||
]
|
]
|
||||||
if len(model.liabilities) < initial:
|
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)}")
|
console.print(f"[green]{t('global.ok')}[/green] {t('cmd.config.liability_remove.ok', id=identifier)}")
|
||||||
else:
|
else:
|
||||||
console.print(f"[red]{t('cmd.config.liability_remove.err', id=identifier)}[/red]")
|
console.print(f"[red]{t('cmd.config.liability_remove.err', id=identifier)}[/red]")
|
||||||
@@ -417,14 +399,14 @@ def liability_remove(
|
|||||||
# ── helpers ────────────────────────────────────────────────────
|
# ── 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:
|
for a in model.accounts:
|
||||||
if str(a.id) == identifier or a.name == identifier:
|
if str(a.id) == identifier or a.name == identifier:
|
||||||
return str(a.id)
|
return str(a.id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _account_name(model: FinancialModel, account_id: str) -> str:
|
def _account_name(model, account_id: str) -> str:
|
||||||
for a in model.accounts:
|
for a in model.accounts:
|
||||||
if str(a.id) == account_id:
|
if str(a.id) == account_id:
|
||||||
return a.name
|
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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)))
|
||||||
@@ -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)}")
|
||||||
@@ -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)}")
|
||||||
@@ -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()
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Пути к данным и экспортам."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DATA_DIR: Path = Path("data")
|
||||||
|
MODEL_PATH: Path = DATA_DIR / "model.json"
|
||||||
@@ -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
@@ -15,7 +15,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
cf = "infrastructure.cli.main:app"
|
cf = "infrastructure.cli.app:app"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["domain*", "application*", "infrastructure*"]
|
include = ["domain*", "application*", "infrastructure*"]
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
from typer.testing import CliRunner
|
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()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user