289 lines
14 KiB
Python
289 lines
14 KiB
Python
from __future__ import annotations
|
|
from pathlib import Path
|
|
import os
|
|
import sys
|
|
import json
|
|
import typer
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
|
|
from .logging_utils import setup_logging
|
|
from .pipeline import build_in_folder
|
|
from .io import load_invoice_yaml
|
|
from .init import init_invoice_folder, init_invoice_under_root
|
|
from .setup import install_mustang, check_for_mustang_update
|
|
from .editor import open_in_editor
|
|
|
|
app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
console = Console()
|
|
|
|
def _repo_root() -> Path:
|
|
root = Path(__file__).resolve().parent.parent
|
|
return root if (root / "templates/invoice.html.j2").is_file() else Path(__file__).resolve().parent / "data"
|
|
|
|
def _default_template() -> Path:
|
|
return _repo_root() / "templates" / "invoice.html.j2"
|
|
|
|
def _mustang_default_path() -> Path:
|
|
root = _repo_root()
|
|
if root.name == "data":
|
|
return Path.home() / ".local/share/jr-einvoice/tools/Mustang-CLI.jar"
|
|
return root / "tools" / "mustang" / "Mustang-CLI.jar"
|
|
|
|
def _mustang_dir() -> Path:
|
|
return _mustang_default_path().parent
|
|
|
|
def _default_mustang_jar() -> Path | None:
|
|
env = os.environ.get("JR_EINVOICE_MUSTANG_JAR")
|
|
if env:
|
|
return Path(env).expanduser()
|
|
return _mustang_default_path()
|
|
|
|
def _is_gui_session() -> bool:
|
|
return bool(os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY"))
|
|
|
|
def _fail(msg: str, exc: Exception | None = None, exit_code: int = 1):
|
|
console.print(Panel.fit(f"[bold red]ERROR[/bold red]: {msg}", border_style="red"))
|
|
if exc:
|
|
console.print(f"[red]{type(exc).__name__}: {exc}[/red]")
|
|
raise typer.Exit(exit_code)
|
|
|
|
@app.command("setup-mustang")
|
|
def setup_mustang(
|
|
version: str = typer.Option("2.26.0", "--version", help="Mustang-CLI version to download"),
|
|
dest: Path = typer.Option(_mustang_default_path(), "--dest", help="Where to place Mustang-CLI.jar"),
|
|
verify_sha1: bool = typer.Option(True, "--verify-sha1/--no-verify-sha1", help="Verify downloaded jar using Maven .sha1 file"),
|
|
log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"),
|
|
):
|
|
logger = setup_logging(log_level, None)
|
|
try:
|
|
install_mustang(version=version, target_path=dest, logger=logger, verify_sha1=verify_sha1)
|
|
console.print(Panel.fit(f"[bold green]OK[/bold green]\nInstalled Mustang CLI to:\n{dest}", border_style="green"))
|
|
except Exception as e:
|
|
_fail("setup-mustang failed", e)
|
|
|
|
@app.command("mustang-update")
|
|
def mustang_update(
|
|
apply: bool = typer.Option(False, "--apply", help="If set, downloads and installs latest version when available"),
|
|
verify_sha1: bool = typer.Option(True, "--verify-sha1/--no-verify-sha1", help="Verify downloaded jar using Maven .sha1 file"),
|
|
log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"),
|
|
):
|
|
logger = setup_logging(log_level, None)
|
|
try:
|
|
needs, installed, latest = check_for_mustang_update(_mustang_dir(), logger)
|
|
if not needs:
|
|
console.print(Panel.fit(f"[bold green]Up-to-date[/bold green]\nInstalled: {installed}\nLatest: {latest}", border_style="green"))
|
|
return
|
|
if not apply:
|
|
console.print(Panel.fit(
|
|
f"[bold yellow]Update available[/bold yellow]\nInstalled: {installed or '(none)'}\nLatest: {latest}\n\nRun with --apply to install.",
|
|
border_style="yellow"
|
|
))
|
|
return
|
|
install_mustang(version=latest, target_path=_mustang_default_path(), logger=logger, verify_sha1=verify_sha1)
|
|
console.print(Panel.fit(f"[bold green]Updated[/bold green]\nNow installed: {latest}", border_style="green"))
|
|
except Exception as e:
|
|
_fail("mustang-update failed", e)
|
|
|
|
@app.command()
|
|
def init(
|
|
invoice_dir: Path = typer.Argument(..., help="Target invoice folder OR invoices root (when --auto-folder is set)"),
|
|
customer: str | None = typer.Option(None, "--customer", "-c", help="Customer key/name, e.g. 'unicredit'"),
|
|
project: str | None = typer.Option(None, "--project", "-p", help="Project/subject to prefill first line item name"),
|
|
invoice_number: str | None = typer.Option(None, "--invoice-number", "-n", help="Invoice number to prefill. If omitted, can be auto-suggested."),
|
|
invoices_root: Path | None = typer.Option(None, "--invoices-root", help="Folder containing all invoice folders; used to auto-suggest the next invoice number"),
|
|
auto_folder: bool = typer.Option(False, "--auto-folder", help="If set, treat INVOICE_DIR as invoices root and create a subfolder Rechnung_<nr>_<customer> automatically"),
|
|
due_days: int = typer.Option(14, "--due-days", help="Default due date offset in days"),
|
|
force: bool = typer.Option(False, "--force", help="Overwrite invoice.yaml/assets if they already exist"),
|
|
open_editor: bool = typer.Option(False, "--open", help="Open invoice.yaml in $EDITOR after init"),
|
|
open_auto: bool = typer.Option(False, "--open-auto", help="Open invoice.yaml automatically if a GUI session is detected (Wayland/X11) and stdout is a TTY"),
|
|
print_path: bool = typer.Option(False, "--print-path", help="Print the created invoice folder path (useful for scripts)"),
|
|
print_path_only: bool = typer.Option(False, "--print-path-only", help="Only print the path (no rich panel); best for scripts"),
|
|
log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"),
|
|
):
|
|
logger = setup_logging(log_level, None)
|
|
try:
|
|
if auto_folder:
|
|
target = init_invoice_under_root(
|
|
invoices_root=invoice_dir,
|
|
repo_root=_repo_root(),
|
|
logger=logger,
|
|
customer=customer,
|
|
project=project,
|
|
invoice_number=invoice_number,
|
|
due_days=due_days,
|
|
force=force,
|
|
)
|
|
else:
|
|
target = init_invoice_folder(
|
|
invoice_dir,
|
|
_repo_root(),
|
|
logger,
|
|
force=force,
|
|
customer=customer,
|
|
invoice_number=invoice_number,
|
|
invoices_root=invoices_root,
|
|
due_days=due_days,
|
|
project=project,
|
|
)
|
|
|
|
target_path = target.resolve()
|
|
invoice_yaml = target_path / "invoice.yaml"
|
|
|
|
if print_path_only:
|
|
sys.stdout.write(str(target_path) + "\n")
|
|
else:
|
|
console.print(Panel.fit(
|
|
f"[bold green]OK[/bold green]\nInitialized: {target_path}\nEdit: {invoice_yaml}\nThen run: jr-einvoice generate {target_path}",
|
|
border_style="green"
|
|
))
|
|
if print_path:
|
|
console.print(str(target_path))
|
|
|
|
do_open = open_editor or (open_auto and _is_gui_session() and sys.stdout.isatty())
|
|
if do_open:
|
|
open_in_editor(invoice_yaml, logger)
|
|
|
|
except Exception as e:
|
|
_fail("init failed", e)
|
|
|
|
@app.command()
|
|
def generate(
|
|
invoice_dir: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True, readable=True, help="Invoice folder containing invoice.yaml"),
|
|
invoice_filename: str = typer.Option("invoice.yaml", "--file", help="Invoice YAML filename inside the folder"),
|
|
out_subdir: str = typer.Option("out", "--out-subdir", help="Where to write artifacts inside the invoice folder"),
|
|
template: Path = typer.Option(_default_template(), "--template", exists=True, readable=True, help="Jinja2 HTML template"),
|
|
mustang_jar: Path | None = typer.Option(None, "--mustang-jar", help="Path to Mustang CLI jar (default: tools/mustang/Mustang-CLI.jar or env JR_EINVOICE_MUSTANG_JAR)"),
|
|
prefer_weasyprint: bool = typer.Option(True, "--prefer-weasyprint/--prefer-wkhtmltopdf", help="Prefer WeasyPrint if available"),
|
|
log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"),
|
|
print_artifacts: bool = typer.Option(False, "--print-artifacts", help="Print artifact paths after generation"),
|
|
print_artifacts_only: bool = typer.Option(False, "--print-artifacts-only", help="Only print artifact paths (no rich output)"),
|
|
json_out: bool = typer.Option(False, "--json", help="Print artifacts as JSON to stdout (best for CI)"),
|
|
):
|
|
invoice_dir = invoice_dir.resolve()
|
|
try:
|
|
_ = load_invoice_yaml(invoice_dir / invoice_filename)
|
|
except Exception as e:
|
|
_fail(f"Failed to load/validate {invoice_filename} in {invoice_dir}", e)
|
|
|
|
log_file = invoice_dir / out_subdir / "logs" / "run.log"
|
|
logger = setup_logging(log_level, log_file)
|
|
|
|
jar = mustang_jar or _default_mustang_jar()
|
|
if jar is None or not Path(jar).exists():
|
|
_fail("Mustang CLI jar is required (validation is always on). Run: jr-einvoice setup-mustang or jr-einvoice mustang-update --apply")
|
|
|
|
try:
|
|
logger.info("jr-einvoice generate start")
|
|
res = build_in_folder(
|
|
invoice_dir=invoice_dir,
|
|
out_subdir=out_subdir,
|
|
template_path=template,
|
|
logger=logger,
|
|
mustang_jar=Path(jar),
|
|
combine=True,
|
|
do_validate=True,
|
|
prefer_weasyprint=prefer_weasyprint,
|
|
invoice_filename=invoice_filename,
|
|
)
|
|
|
|
artifacts = {
|
|
"invoice_dir": str(res.invoice_dir),
|
|
"out_dir": str(res.out_dir),
|
|
"invoice_pdf": str(res.invoice_pdf),
|
|
"facturx_xml": str(res.facturx_xml),
|
|
"facturx_pdf": str(res.facturx_pdf or ""),
|
|
"validate_report": str(res.validate_report or ""),
|
|
"log": str(log_file),
|
|
}
|
|
|
|
if json_out:
|
|
sys.stdout.write(json.dumps(artifacts, ensure_ascii=False) + "\n")
|
|
return
|
|
|
|
if print_artifacts_only:
|
|
# plain list, one per line
|
|
for k in ["invoice_pdf", "facturx_xml", "facturx_pdf", "validate_report", "log"]:
|
|
v = artifacts.get(k, "")
|
|
if v:
|
|
sys.stdout.write(v + "\n")
|
|
return
|
|
|
|
console.print(Panel.fit(
|
|
f"[bold green]OK[/bold green]\n"
|
|
f"Invoice folder: {res.invoice_dir}\n"
|
|
f"Artifacts: {res.out_dir}\n"
|
|
f"PDF: {res.invoice_pdf}\n"
|
|
f"XML: {res.facturx_xml}\n"
|
|
f"Factur-X PDF: {res.facturx_pdf or '-'}\n"
|
|
f"Validate report: {res.validate_report or '-'}\n"
|
|
f"Log: {log_file}",
|
|
border_style="green"
|
|
))
|
|
|
|
if print_artifacts:
|
|
console.print(str(res.invoice_pdf))
|
|
console.print(str(res.facturx_xml))
|
|
if res.facturx_pdf:
|
|
console.print(str(res.facturx_pdf))
|
|
if res.validate_report:
|
|
console.print(str(res.validate_report))
|
|
console.print(str(log_file))
|
|
|
|
except Exception as e:
|
|
logger.exception("Generate failed")
|
|
_fail("Generate failed. See out/logs/run.log for details.", e)
|
|
|
|
@app.command("monthly")
|
|
def monthly(
|
|
root: Path = typer.Argument(..., help="Private invoice root"),
|
|
customer: str = typer.Option(..., "--customer", "-c"),
|
|
month: str = typer.Option(..., "--month", help="Service month YYYY-MM"),
|
|
hours: str = typer.Option(..., "--hours", help="Hours, decimal point allowed"),
|
|
number: str = typer.Option(..., "--number", "-n", help="Unique invoice number, including existing PDF invoices"),
|
|
rate: str | None = typer.Option(None, "--rate", help="Override preset hourly net rate"),
|
|
issued: str | None = typer.Option(None, "--date", help="Invoice date YYYY-MM-DD; default today"),
|
|
due_days: int = typer.Option(30, "--due-days", min=0),
|
|
):
|
|
"""Monatsrechnung mit Stunden, Zeitraum, Kundenstammdaten und Zahlungsziel anlegen."""
|
|
from decimal import Decimal
|
|
from datetime import date
|
|
from .monthly import monthly_invoice
|
|
try:
|
|
target = monthly_invoice(root, _repo_root(), setup_logging("INFO", None), customer,
|
|
month, Decimal(hours), number, date.fromisoformat(issued) if issued else None,
|
|
Decimal(rate) if rate else None, due_days)
|
|
sys.stdout.write(str(target) + "\n")
|
|
except Exception as e:
|
|
_fail("Monatsrechnung konnte nicht angelegt werden", e)
|
|
|
|
@app.command("email")
|
|
def email_draft(
|
|
invoice_dir: Path = typer.Argument(..., exists=True, file_okay=False),
|
|
recipient: str | None = typer.Option(None, "--to", help="Agreed invoice recipient"),
|
|
mustang_jar: Path | None = typer.Option(None, "--mustang-jar"),
|
|
):
|
|
"""Rechnung neu erzeugen, validieren und Versandentwurf (.eml) mit PDF-Anhang vorbereiten."""
|
|
from .delivery import prepare_email
|
|
logger = setup_logging("INFO", invoice_dir / 'out/logs/run.log')
|
|
try:
|
|
path = prepare_email(invoice_dir.resolve(), mustang_jar or _default_mustang_jar(), logger, recipient)
|
|
console.print(f"Versandentwurf: {path}\nIm Mailprogramm öffnen, prüfen und absenden.")
|
|
except Exception as e:
|
|
logger.exception("Email preparation failed")
|
|
_fail("Versandentwurf konnte nicht erstellt werden", e)
|
|
|
|
@app.command("validate")
|
|
def validate_file(
|
|
source: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
report: Path = typer.Option(Path("validation-report.xml"), "--report"),
|
|
mustang_jar: Path | None = typer.Option(None, "--mustang-jar"),
|
|
):
|
|
"""Eingehende XML- oder ZUGFeRD-PDF-Rechnung lokal technisch prüfen."""
|
|
from .mustang import validate
|
|
try:
|
|
validate(mustang_jar or _default_mustang_jar(), source.resolve(), report, setup_logging("INFO", None))
|
|
console.print(f"Technisch gültig. Prüfbericht: {report}")
|
|
except Exception as e:
|
|
_fail("Validierung fehlgeschlagen", e)
|