lxw-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lxw_cli/__init__.py +1 -0
- lxw_cli/__main__.py +4 -0
- lxw_cli/cli.py +207 -0
- lxw_cli/commands/__init__.py +0 -0
- lxw_cli/commands/_common.py +40 -0
- lxw_cli/commands/articles.py +110 -0
- lxw_cli/commands/contacts.py +215 -0
- lxw_cli/commands/delivery_notes.py +114 -0
- lxw_cli/commands/invoices.py +179 -0
- lxw_cli/commands/mcp.py +167 -0
- lxw_cli/commands/orders.py +117 -0
- lxw_cli/commands/quotations.py +117 -0
- lxw_cli/commands/vouchers.py +121 -0
- lxw_cli/config.py +235 -0
- lxw_cli/core/__init__.py +10 -0
- lxw_cli/core/client.py +200 -0
- lxw_cli/core/constants.py +25 -0
- lxw_cli/core/errors.py +19 -0
- lxw_cli/core/models.py +30 -0
- lxw_cli/core/services.py +598 -0
- lxw_cli/mcp_server.py +367 -0
- lxw_cli/output.py +191 -0
- lxw_cli/tui/__init__.py +7 -0
- lxw_cli/tui/app.py +391 -0
- lxw_cli/tui/detail.py +220 -0
- lxw_cli/tui/entities.py +174 -0
- lxw_cli/tui/order_form.py +286 -0
- lxw_cli-0.1.0.dist-info/METADATA +403 -0
- lxw_cli-0.1.0.dist-info/RECORD +32 -0
- lxw_cli-0.1.0.dist-info/WHEEL +4 -0
- lxw_cli-0.1.0.dist-info/entry_points.txt +4 -0
- lxw_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lxw_cli.commands._common import ARCHIVED_HINT, load_json_arg, state
|
|
8
|
+
from lxw_cli.core import services
|
|
9
|
+
from lxw_cli.core.constants import DEFAULT_VOUCHER_STATUSES
|
|
10
|
+
from lxw_cli.output import print_count, render, working, write_binary
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
LIST_COLUMNS = [
|
|
15
|
+
"id",
|
|
16
|
+
"voucherNumber",
|
|
17
|
+
"voucherDate",
|
|
18
|
+
"voucherStatus",
|
|
19
|
+
"contactName",
|
|
20
|
+
"totalAmount",
|
|
21
|
+
"currency",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command(
|
|
26
|
+
"list",
|
|
27
|
+
epilog="""\
|
|
28
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
29
|
+
|
|
30
|
+
Offene Rechnungen: [green]lxw invoices list --status open[/green]
|
|
31
|
+
|
|
32
|
+
Alle als CSV: [green]lxw --csv invoices list --all[/green]
|
|
33
|
+
|
|
34
|
+
Nach Rechnungsnummer: [green]lxw invoices list --number FB2600682[/green]
|
|
35
|
+
|
|
36
|
+
Inkl. archivierte: [green]lxw invoices list --include-archived[/green]
|
|
37
|
+
""",
|
|
38
|
+
)
|
|
39
|
+
def list_invoices(
|
|
40
|
+
ctx: typer.Context,
|
|
41
|
+
limit: int = typer.Option(
|
|
42
|
+
25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
|
|
43
|
+
),
|
|
44
|
+
fetch_all: bool = typer.Option(
|
|
45
|
+
False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
|
|
46
|
+
),
|
|
47
|
+
voucher_status: str = typer.Option(
|
|
48
|
+
DEFAULT_VOUCHER_STATUSES,
|
|
49
|
+
"--status",
|
|
50
|
+
help="Komma-separiert: draft,open,paid,paidoff,voided,overdue,accepted,rejected.",
|
|
51
|
+
),
|
|
52
|
+
voucher_number: str | None = typer.Option(
|
|
53
|
+
None, "--number", help="Filter: exakte Rechnungsnummer."
|
|
54
|
+
),
|
|
55
|
+
contact_id: str | None = typer.Option(
|
|
56
|
+
None, "--contact-id", help="Filter: Kontakt-UUID (Kunde)."
|
|
57
|
+
),
|
|
58
|
+
include_archived: bool = typer.Option(
|
|
59
|
+
False,
|
|
60
|
+
"--include-archived",
|
|
61
|
+
help="Auch archivierte Rechnungen anzeigen (Standard: ausgeblendet).",
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Rechnungen auflisten.
|
|
65
|
+
|
|
66
|
+
Listet Rechnungen aus /v1/voucherlist (salesinvoice, invoice,
|
|
67
|
+
downpaymentinvoice). Standardmäßig werden bis zu 25 Einträge gezeigt — mit
|
|
68
|
+
--all bzw. --limit 0 werden alle Seiten geladen. Filtern lässt sich nach
|
|
69
|
+
Status, exakter Rechnungsnummer und Kunde.
|
|
70
|
+
|
|
71
|
+
Archivierte Rechnungen sind standardmäßig ausgeblendet; --include-archived
|
|
72
|
+
zeigt sie. Am Ende wird die Gesamtzahl ausgegeben. Tipp: get/pdf akzeptieren
|
|
73
|
+
UUID oder Rechnungsnummer (z.B. FB2600682).
|
|
74
|
+
"""
|
|
75
|
+
s = state(ctx)
|
|
76
|
+
with working("Lade Rechnungen …"):
|
|
77
|
+
result = services.list_invoices(
|
|
78
|
+
s.client,
|
|
79
|
+
status=voucher_status,
|
|
80
|
+
number=voucher_number,
|
|
81
|
+
contact_id=contact_id,
|
|
82
|
+
include_archived=include_archived,
|
|
83
|
+
limit=limit,
|
|
84
|
+
fetch_all=fetch_all,
|
|
85
|
+
)
|
|
86
|
+
render(result.items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
|
|
87
|
+
note = None if include_archived else ARCHIVED_HINT
|
|
88
|
+
print_count(len(result.items), result.total, noun="Rechnungen", note=note)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command(
|
|
92
|
+
"get",
|
|
93
|
+
epilog="""\
|
|
94
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
95
|
+
|
|
96
|
+
Nach Rechnungsnummer: [green]lxw invoices get FB2600682[/green]
|
|
97
|
+
|
|
98
|
+
Betrag via jq: [green]lxw --json invoices get FB2600682 | jq .totalGrossAmount[/green]
|
|
99
|
+
""",
|
|
100
|
+
)
|
|
101
|
+
def get_invoice(
|
|
102
|
+
ctx: typer.Context,
|
|
103
|
+
invoice: str = typer.Argument(..., help="UUID oder Rechnungsnummer (z.B. FB2600682)."),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Eine Rechnung im Detail abrufen.
|
|
106
|
+
|
|
107
|
+
Akzeptiert die UUID oder die Rechnungsnummer (z.B. FB2600682) — die Nummer
|
|
108
|
+
wird automatisch aufgelöst. Mit --json gibt es die vollständigen Rohdaten
|
|
109
|
+
für die Weiterverarbeitung.
|
|
110
|
+
"""
|
|
111
|
+
s = state(ctx)
|
|
112
|
+
with working("Lade Rechnung …"):
|
|
113
|
+
data = services.get_invoice(s.client, invoice)
|
|
114
|
+
render(data, s.output_format, output_path=s.output_path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command(
|
|
118
|
+
"pdf",
|
|
119
|
+
epilog="""\
|
|
120
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
121
|
+
|
|
122
|
+
Ins aktuelle Verzeichnis: [green]lxw invoices pdf FB2600682[/green]
|
|
123
|
+
|
|
124
|
+
In ein Verzeichnis (Auto-Name): [green]lxw invoices pdf FB2600682 -o ~/Rechnungen[/green]
|
|
125
|
+
|
|
126
|
+
Mit festem Dateinamen: [green]lxw invoices pdf FB2600682 -o rechnung.pdf[/green]
|
|
127
|
+
""",
|
|
128
|
+
)
|
|
129
|
+
def download_pdf(
|
|
130
|
+
ctx: typer.Context,
|
|
131
|
+
invoice: str = typer.Argument(..., help="UUID oder Rechnungsnummer."),
|
|
132
|
+
output: Path | None = typer.Option(
|
|
133
|
+
None,
|
|
134
|
+
"--output",
|
|
135
|
+
"-o",
|
|
136
|
+
help="Ziel-Datei oder -Verzeichnis (Standard: aktuelles Verzeichnis).",
|
|
137
|
+
),
|
|
138
|
+
) -> None:
|
|
139
|
+
"""PDF einer Rechnung herunterladen.
|
|
140
|
+
|
|
141
|
+
Akzeptiert UUID oder Rechnungsnummer. Ohne -o landet die Datei im aktuellen
|
|
142
|
+
Verzeichnis (z.B. invoice-FB2600682.pdf); zeigt -o auf ein Verzeichnis, wird
|
|
143
|
+
der Dateiname automatisch vergeben. Hinweis: Entwürfe haben noch kein PDF.
|
|
144
|
+
"""
|
|
145
|
+
s = state(ctx)
|
|
146
|
+
with working("Lade PDF …"):
|
|
147
|
+
data = services.download_invoice_pdf(s.client, invoice)
|
|
148
|
+
write_binary(data, output, default_name=f"invoice-{invoice}.pdf")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command(
|
|
152
|
+
"create-draft",
|
|
153
|
+
epilog="""\
|
|
154
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
155
|
+
|
|
156
|
+
Aus einer Datei: [green]lxw invoices create-draft --body @rechnung.json[/green]
|
|
157
|
+
|
|
158
|
+
Inline: [green]lxw invoices create-draft --body '{…}'[/green]
|
|
159
|
+
""",
|
|
160
|
+
)
|
|
161
|
+
def create_draft(
|
|
162
|
+
ctx: typer.Context,
|
|
163
|
+
body: str = typer.Option(
|
|
164
|
+
...,
|
|
165
|
+
"--body",
|
|
166
|
+
help="JSON-Body inline oder @datei.json (siehe API-Doku).",
|
|
167
|
+
),
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Neue Rechnung als Draft anlegen (kein Finalize).
|
|
170
|
+
|
|
171
|
+
Erwartet den vollständigen JSON-Body der Lexware-API — inline oder als
|
|
172
|
+
@pfad.json. Die Rechnung wird als Entwurf gespeichert (nicht finalisiert).
|
|
173
|
+
Schema siehe https://developers.lexware.io/docs/.
|
|
174
|
+
"""
|
|
175
|
+
s = state(ctx)
|
|
176
|
+
payload = load_json_arg(body)
|
|
177
|
+
with working("Lege Rechnung an …"):
|
|
178
|
+
result = services.create_invoice(s.client, payload)
|
|
179
|
+
render(result, s.output_format, output_path=s.output_path)
|
lxw_cli/commands/mcp.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Subcommand `lxw mcp …` — manages Claude Code/Desktop integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from dotenv import dotenv_values
|
|
12
|
+
|
|
13
|
+
from lxw_cli.config import ENV_KEY, global_env_path, load_config, store_key
|
|
14
|
+
from lxw_cli.output import console, err_console
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
MCP_NAME = "lexware"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _check_claude() -> None:
|
|
22
|
+
if not shutil.which("claude"):
|
|
23
|
+
err_console.print(
|
|
24
|
+
"[red]Fehler:[/red] `claude` CLI nicht gefunden. "
|
|
25
|
+
"Installiere zuerst Claude Code (https://claude.com/claude-code)."
|
|
26
|
+
)
|
|
27
|
+
raise typer.Exit(code=2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _mcp_command() -> list[str]:
|
|
31
|
+
"""Build the command Claude will use to launch the MCP server.
|
|
32
|
+
|
|
33
|
+
Prefer the `lxw-mcp` console script when it's on PATH (works for
|
|
34
|
+
pipx-installed setups). Otherwise fall back to the current interpreter
|
|
35
|
+
plus `-m lxw_cli.mcp_server` so the venv path is captured even when
|
|
36
|
+
`.venv/bin` isn't on the system PATH.
|
|
37
|
+
"""
|
|
38
|
+
on_path = shutil.which("lxw-mcp")
|
|
39
|
+
if on_path:
|
|
40
|
+
return [on_path]
|
|
41
|
+
# Look for the entry-point next to the running python (typical venv layout;
|
|
42
|
+
# on Windows console scripts get an .exe suffix)
|
|
43
|
+
exe_suffix = ".exe" if sys.platform == "win32" else ""
|
|
44
|
+
candidate = Path(sys.executable).parent / f"lxw-mcp{exe_suffix}"
|
|
45
|
+
if candidate.exists():
|
|
46
|
+
return [str(candidate)]
|
|
47
|
+
return [sys.executable, "-m", "lxw_cli.mcp_server"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ensure_global_key(api_key: str) -> None:
|
|
51
|
+
"""Make sure the MCP server can resolve the key from the global config.
|
|
52
|
+
|
|
53
|
+
The key is deliberately NOT passed via `claude mcp add -e …` — that would
|
|
54
|
+
expose it in the process list and store a second plaintext copy in the
|
|
55
|
+
Claude config. The server resolves it itself from the global `.env`
|
|
56
|
+
(chmod 600), so we only have to guarantee it exists there.
|
|
57
|
+
"""
|
|
58
|
+
path = global_env_path()
|
|
59
|
+
existing = (dotenv_values(path).get(ENV_KEY) or "").strip() if path.is_file() else ""
|
|
60
|
+
if not existing:
|
|
61
|
+
stored = store_key(api_key)
|
|
62
|
+
err_console.print(
|
|
63
|
+
f"[dim]API-Key in {stored} hinterlegt (wird vom MCP-Server gelesen).[/dim]"
|
|
64
|
+
)
|
|
65
|
+
elif existing != api_key:
|
|
66
|
+
err_console.print(
|
|
67
|
+
f"[yellow]Hinweis:[/yellow] {path} enthält einen anderen API-Key — "
|
|
68
|
+
"der MCP-Server verwendet den dort hinterlegten."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("install-claude")
|
|
73
|
+
def install_claude(
|
|
74
|
+
scope: str = typer.Option(
|
|
75
|
+
"user", "--scope", help="user (global), project (per repo), oder local."
|
|
76
|
+
),
|
|
77
|
+
force: bool = typer.Option(
|
|
78
|
+
False, "--force", help="Vorher entfernen, falls bereits registriert."
|
|
79
|
+
),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Registriert den Lexware-MCP-Server bei Claude Code."""
|
|
82
|
+
_check_claude()
|
|
83
|
+
config = load_config() # validates LEXWARE_API_KEY
|
|
84
|
+
_ensure_global_key(config.api_key)
|
|
85
|
+
|
|
86
|
+
server_cmd = _mcp_command()
|
|
87
|
+
|
|
88
|
+
if force:
|
|
89
|
+
subprocess.run(
|
|
90
|
+
["claude", "mcp", "remove", MCP_NAME, "--scope", scope],
|
|
91
|
+
check=False,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
cmd = [
|
|
96
|
+
"claude",
|
|
97
|
+
"mcp",
|
|
98
|
+
"add",
|
|
99
|
+
MCP_NAME,
|
|
100
|
+
"--scope",
|
|
101
|
+
scope,
|
|
102
|
+
"--",
|
|
103
|
+
*server_cmd,
|
|
104
|
+
]
|
|
105
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
106
|
+
if result.returncode != 0:
|
|
107
|
+
err_console.print(f"[red]claude mcp add fehlgeschlagen:[/red]\n{result.stderr}")
|
|
108
|
+
if "already exists" in (result.stderr or "").lower():
|
|
109
|
+
err_console.print(
|
|
110
|
+
"[yellow]Hinweis:[/yellow] Mit `--force` kannst du die "
|
|
111
|
+
"bestehende Registrierung überschreiben."
|
|
112
|
+
)
|
|
113
|
+
raise typer.Exit(code=result.returncode)
|
|
114
|
+
console.print(
|
|
115
|
+
f"[green]✓[/green] Lexware MCP-Server bei Claude Code registriert "
|
|
116
|
+
f"(scope={scope})."
|
|
117
|
+
)
|
|
118
|
+
console.print(
|
|
119
|
+
"Test: Öffne Claude Code und frag z.B. "
|
|
120
|
+
"[bold]\"Wie viele offene Rechnungen habe ich?\"[/bold]"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("uninstall-claude")
|
|
125
|
+
def uninstall_claude(
|
|
126
|
+
scope: str = typer.Option("user", "--scope", help="Gleicher Scope wie beim Install."),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Entfernt den Lexware-MCP-Server aus Claude Code."""
|
|
129
|
+
_check_claude()
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
["claude", "mcp", "remove", MCP_NAME, "--scope", scope],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
if result.returncode != 0:
|
|
136
|
+
err_console.print(f"[red]Fehler:[/red] {result.stderr}")
|
|
137
|
+
raise typer.Exit(code=result.returncode)
|
|
138
|
+
console.print(f"[green]✓[/green] Lexware MCP-Server entfernt (scope={scope}).")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("status")
|
|
142
|
+
def status() -> None:
|
|
143
|
+
"""Zeigt, ob der Lexware-MCP-Server aktuell bei Claude Code registriert ist."""
|
|
144
|
+
_check_claude()
|
|
145
|
+
result = subprocess.run(
|
|
146
|
+
["claude", "mcp", "list"], capture_output=True, text=True
|
|
147
|
+
)
|
|
148
|
+
if result.returncode != 0:
|
|
149
|
+
err_console.print(f"[red]Fehler:[/red] {result.stderr}")
|
|
150
|
+
raise typer.Exit(code=result.returncode)
|
|
151
|
+
if MCP_NAME in result.stdout:
|
|
152
|
+
for line in result.stdout.splitlines():
|
|
153
|
+
if MCP_NAME in line:
|
|
154
|
+
console.print(f"[green]✓[/green] {line.strip()}")
|
|
155
|
+
else:
|
|
156
|
+
console.print(
|
|
157
|
+
"[yellow]✗[/yellow] Lexware ist nicht registriert. "
|
|
158
|
+
"Setup: [bold]lxw mcp install-claude[/bold]"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command("serve")
|
|
163
|
+
def serve() -> None:
|
|
164
|
+
"""Startet den MCP-Server (intern von Claude Code aufgerufen — selten manuell nötig)."""
|
|
165
|
+
from lxw_cli.mcp_server import run as run_server
|
|
166
|
+
|
|
167
|
+
run_server()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lxw_cli.commands._common import ARCHIVED_HINT, load_json_arg, state
|
|
8
|
+
from lxw_cli.core import services
|
|
9
|
+
from lxw_cli.core.constants import DEFAULT_VOUCHER_STATUSES
|
|
10
|
+
from lxw_cli.output import print_count, render, working, write_binary
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
LIST_COLUMNS = [
|
|
15
|
+
"id",
|
|
16
|
+
"voucherNumber",
|
|
17
|
+
"voucherDate",
|
|
18
|
+
"voucherStatus",
|
|
19
|
+
"contactName",
|
|
20
|
+
"totalAmount",
|
|
21
|
+
"currency",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command(
|
|
26
|
+
"list",
|
|
27
|
+
epilog="""\
|
|
28
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
29
|
+
|
|
30
|
+
Offene Aufträge: [green]lxw orders list --status open[/green]
|
|
31
|
+
|
|
32
|
+
Alle als CSV: [green]lxw --csv -o auftraege.csv orders list --all[/green]
|
|
33
|
+
|
|
34
|
+
Inkl. archivierte: [green]lxw orders list --include-archived[/green]
|
|
35
|
+
""",
|
|
36
|
+
)
|
|
37
|
+
def list_orders(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
limit: int = typer.Option(
|
|
40
|
+
25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
|
|
41
|
+
),
|
|
42
|
+
fetch_all: bool = typer.Option(
|
|
43
|
+
False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
|
|
44
|
+
),
|
|
45
|
+
voucher_status: str = typer.Option(
|
|
46
|
+
DEFAULT_VOUCHER_STATUSES,
|
|
47
|
+
"--status",
|
|
48
|
+
help="Komma-separiert (z.B. draft,open,accepted,rejected).",
|
|
49
|
+
),
|
|
50
|
+
include_archived: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--include-archived",
|
|
53
|
+
help="Auch archivierte Aufträge anzeigen (Standard: ausgeblendet).",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Aufträge (Auftragsbestätigungen) auflisten (über /v1/voucherlist).
|
|
57
|
+
|
|
58
|
+
Standardmäßig bis zu 25 Aufträge (--all bzw. --limit 0 lädt alle),
|
|
59
|
+
filterbar nach Status. Archivierte Aufträge sind standardmäßig
|
|
60
|
+
ausgeblendet; --include-archived zeigt sie. get/pdf akzeptieren UUID oder
|
|
61
|
+
Auftragsnummer.
|
|
62
|
+
"""
|
|
63
|
+
s = state(ctx)
|
|
64
|
+
with working("Lade Aufträge …"):
|
|
65
|
+
result = services.list_order_confirmations(
|
|
66
|
+
s.client,
|
|
67
|
+
status=voucher_status,
|
|
68
|
+
include_archived=include_archived,
|
|
69
|
+
limit=limit,
|
|
70
|
+
fetch_all=fetch_all,
|
|
71
|
+
)
|
|
72
|
+
render(result.items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
|
|
73
|
+
note = None if include_archived else ARCHIVED_HINT
|
|
74
|
+
print_count(len(result.items), result.total, noun="Aufträge", note=note)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("get")
|
|
78
|
+
def get_order(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
order: str = typer.Argument(..., help="UUID oder Auftragsnummer."),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Einen Auftrag im Detail abrufen. Akzeptiert UUID oder Auftragsnummer."""
|
|
83
|
+
s = state(ctx)
|
|
84
|
+
with working("Lade Auftrag …"):
|
|
85
|
+
data = services.get_order_confirmation(s.client, order)
|
|
86
|
+
render(data, s.output_format, output_path=s.output_path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("pdf")
|
|
90
|
+
def download_pdf(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
order: str = typer.Argument(..., help="UUID oder Auftragsnummer."),
|
|
93
|
+
output: Path | None = typer.Option(
|
|
94
|
+
None,
|
|
95
|
+
"--output",
|
|
96
|
+
"-o",
|
|
97
|
+
help="Ziel-Datei oder -Verzeichnis (Standard: aktuelles Verzeichnis).",
|
|
98
|
+
),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""PDF eines Auftrags herunterladen. Akzeptiert UUID oder Auftragsnummer."""
|
|
101
|
+
s = state(ctx)
|
|
102
|
+
with working("Lade PDF …"):
|
|
103
|
+
data = services.download_order_confirmation_pdf(s.client, order)
|
|
104
|
+
write_binary(data, output, default_name=f"order-{order}.pdf")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("create-draft")
|
|
108
|
+
def create_draft(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
body: str = typer.Option(..., "--body", help="JSON-Body inline oder @datei.json."),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Neuen Auftrag als Draft anlegen."""
|
|
113
|
+
s = state(ctx)
|
|
114
|
+
payload = load_json_arg(body)
|
|
115
|
+
with working("Lege Auftrag an …"):
|
|
116
|
+
result = services.create_order_confirmation(s.client, payload)
|
|
117
|
+
render(result, s.output_format, output_path=s.output_path)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from lxw_cli.commands._common import ARCHIVED_HINT, load_json_arg, state
|
|
8
|
+
from lxw_cli.core import services
|
|
9
|
+
from lxw_cli.core.constants import DEFAULT_VOUCHER_STATUSES
|
|
10
|
+
from lxw_cli.output import print_count, render, working, write_binary
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
LIST_COLUMNS = [
|
|
15
|
+
"id",
|
|
16
|
+
"voucherNumber",
|
|
17
|
+
"voucherDate",
|
|
18
|
+
"voucherStatus",
|
|
19
|
+
"contactName",
|
|
20
|
+
"totalAmount",
|
|
21
|
+
"currency",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command(
|
|
26
|
+
"list",
|
|
27
|
+
epilog="""\
|
|
28
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
29
|
+
|
|
30
|
+
Offene Angebote: [green]lxw quotations list --status open[/green]
|
|
31
|
+
|
|
32
|
+
Alle als CSV: [green]lxw --csv -o angebote.csv quotations list --all[/green]
|
|
33
|
+
|
|
34
|
+
Inkl. archivierte: [green]lxw quotations list --include-archived[/green]
|
|
35
|
+
""",
|
|
36
|
+
)
|
|
37
|
+
def list_quotations(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
limit: int = typer.Option(
|
|
40
|
+
25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
|
|
41
|
+
),
|
|
42
|
+
fetch_all: bool = typer.Option(
|
|
43
|
+
False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
|
|
44
|
+
),
|
|
45
|
+
voucher_status: str = typer.Option(
|
|
46
|
+
DEFAULT_VOUCHER_STATUSES,
|
|
47
|
+
"--status",
|
|
48
|
+
help="Komma-separiert (z.B. draft,open,accepted,rejected).",
|
|
49
|
+
),
|
|
50
|
+
include_archived: bool = typer.Option(
|
|
51
|
+
False,
|
|
52
|
+
"--include-archived",
|
|
53
|
+
help="Auch archivierte Angebote anzeigen (Standard: ausgeblendet).",
|
|
54
|
+
),
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Angebote auflisten (über /v1/voucherlist).
|
|
57
|
+
|
|
58
|
+
Standardmäßig bis zu 25 Angebote (--all bzw. --limit 0 lädt alle),
|
|
59
|
+
filterbar nach Status. Archivierte Angebote sind standardmäßig
|
|
60
|
+
ausgeblendet; --include-archived zeigt sie. get/pdf akzeptieren UUID oder
|
|
61
|
+
Angebotsnummer (z.B. AG5241415).
|
|
62
|
+
"""
|
|
63
|
+
s = state(ctx)
|
|
64
|
+
with working("Lade Angebote …"):
|
|
65
|
+
result = services.list_quotations(
|
|
66
|
+
s.client,
|
|
67
|
+
status=voucher_status,
|
|
68
|
+
include_archived=include_archived,
|
|
69
|
+
limit=limit,
|
|
70
|
+
fetch_all=fetch_all,
|
|
71
|
+
)
|
|
72
|
+
render(result.items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
|
|
73
|
+
note = None if include_archived else ARCHIVED_HINT
|
|
74
|
+
print_count(len(result.items), result.total, noun="Angebote", note=note)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("get")
|
|
78
|
+
def get_quotation(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
quotation: str = typer.Argument(..., help="UUID oder Angebotsnummer."),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Ein Angebot im Detail abrufen. Akzeptiert UUID oder Angebotsnummer."""
|
|
83
|
+
s = state(ctx)
|
|
84
|
+
with working("Lade Angebot …"):
|
|
85
|
+
data = services.get_quotation(s.client, quotation)
|
|
86
|
+
render(data, s.output_format, output_path=s.output_path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("pdf")
|
|
90
|
+
def download_pdf(
|
|
91
|
+
ctx: typer.Context,
|
|
92
|
+
quotation: str = typer.Argument(..., help="UUID oder Angebotsnummer."),
|
|
93
|
+
output: Path | None = typer.Option(
|
|
94
|
+
None,
|
|
95
|
+
"--output",
|
|
96
|
+
"-o",
|
|
97
|
+
help="Ziel-Datei oder -Verzeichnis (Standard: aktuelles Verzeichnis).",
|
|
98
|
+
),
|
|
99
|
+
) -> None:
|
|
100
|
+
"""PDF eines Angebots herunterladen. Akzeptiert UUID oder Angebotsnummer."""
|
|
101
|
+
s = state(ctx)
|
|
102
|
+
with working("Lade PDF …"):
|
|
103
|
+
data = services.download_quotation_pdf(s.client, quotation)
|
|
104
|
+
write_binary(data, output, default_name=f"quotation-{quotation}.pdf")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("create-draft")
|
|
108
|
+
def create_draft(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
body: str = typer.Option(..., "--body", help="JSON-Body inline oder @datei.json."),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Neues Angebot als Draft anlegen."""
|
|
113
|
+
s = state(ctx)
|
|
114
|
+
payload = load_json_arg(body)
|
|
115
|
+
with working("Lege Angebot an …"):
|
|
116
|
+
result = services.create_quotation(s.client, payload)
|
|
117
|
+
render(result, s.output_format, output_path=s.output_path)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from lxw_cli.commands._common import ARCHIVED_HINT, load_json_arg, state
|
|
6
|
+
from lxw_cli.core import services
|
|
7
|
+
from lxw_cli.core.constants import DEFAULT_VOUCHER_STATUSES, DEFAULT_VOUCHER_TYPES
|
|
8
|
+
from lxw_cli.output import print_count, render, working
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(no_args_is_help=True)
|
|
11
|
+
|
|
12
|
+
LIST_COLUMNS = [
|
|
13
|
+
"id",
|
|
14
|
+
"voucherType",
|
|
15
|
+
"voucherStatus",
|
|
16
|
+
"voucherNumber",
|
|
17
|
+
"voucherDate",
|
|
18
|
+
"contactName",
|
|
19
|
+
"totalAmount",
|
|
20
|
+
"currency",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command(
|
|
25
|
+
"list",
|
|
26
|
+
epilog="""\
|
|
27
|
+
[bold cyan]Beispiele[/bold cyan]
|
|
28
|
+
|
|
29
|
+
Nur Eingangsrechnungen: [green]lxw vouchers list --type purchaseinvoice[/green]
|
|
30
|
+
|
|
31
|
+
Offene & überfällige: [green]lxw vouchers list --status open,overdue[/green]
|
|
32
|
+
|
|
33
|
+
Alle eines Kontakts: [green]lxw vouchers list --contact-id <uuid> --all[/green]
|
|
34
|
+
|
|
35
|
+
Inkl. archivierte: [green]lxw vouchers list --include-archived[/green]
|
|
36
|
+
""",
|
|
37
|
+
)
|
|
38
|
+
def list_vouchers(
|
|
39
|
+
ctx: typer.Context,
|
|
40
|
+
limit: int = typer.Option(
|
|
41
|
+
25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
|
|
42
|
+
),
|
|
43
|
+
fetch_all: bool = typer.Option(
|
|
44
|
+
False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
|
|
45
|
+
),
|
|
46
|
+
voucher_type: str = typer.Option(
|
|
47
|
+
DEFAULT_VOUCHER_TYPES,
|
|
48
|
+
"--type",
|
|
49
|
+
help=(
|
|
50
|
+
"Komma-separiert: salesinvoice,salescreditnote,purchaseinvoice,"
|
|
51
|
+
"purchasecreditnote,invoice,downpaymentinvoice,creditnote,"
|
|
52
|
+
"orderconfirmation,quotation,deliverynote."
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
voucher_status: str = typer.Option(
|
|
56
|
+
DEFAULT_VOUCHER_STATUSES,
|
|
57
|
+
"--status",
|
|
58
|
+
help="Komma-separiert (z.B. open,paid,paidoff).",
|
|
59
|
+
),
|
|
60
|
+
voucher_number: str | None = typer.Option(
|
|
61
|
+
None, "--number", help="Filter: exakte Belegnummer."
|
|
62
|
+
),
|
|
63
|
+
contact_id: str | None = typer.Option(
|
|
64
|
+
None, "--contact-id", help="Filter: Kontakt-UUID."
|
|
65
|
+
),
|
|
66
|
+
include_archived: bool = typer.Option(
|
|
67
|
+
False,
|
|
68
|
+
"--include-archived",
|
|
69
|
+
help="Auch archivierte Belege anzeigen (Standard: ausgeblendet).",
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Belege auflisten (alle voucher-types).
|
|
73
|
+
|
|
74
|
+
Anders als 'invoices' deckt dieser Befehl alle Beleg-Typen ab — von
|
|
75
|
+
salesinvoice über purchaseinvoice bis creditnote (siehe --type).
|
|
76
|
+
Standardmäßig bis zu 25 Einträge (--all lädt alle), filterbar nach Typ,
|
|
77
|
+
Status, exakter Belegnummer und Kontakt.
|
|
78
|
+
|
|
79
|
+
Archivierte Belege sind standardmäßig ausgeblendet; --include-archived
|
|
80
|
+
zeigt sie. Am Ende wird die Gesamtzahl ausgegeben.
|
|
81
|
+
"""
|
|
82
|
+
s = state(ctx)
|
|
83
|
+
with working("Lade Belege …"):
|
|
84
|
+
result = services.list_vouchers(
|
|
85
|
+
s.client,
|
|
86
|
+
voucher_type=voucher_type,
|
|
87
|
+
status=voucher_status,
|
|
88
|
+
number=voucher_number,
|
|
89
|
+
contact_id=contact_id,
|
|
90
|
+
include_archived=include_archived,
|
|
91
|
+
limit=limit,
|
|
92
|
+
fetch_all=fetch_all,
|
|
93
|
+
)
|
|
94
|
+
render(result.items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
|
|
95
|
+
note = None if include_archived else ARCHIVED_HINT
|
|
96
|
+
print_count(len(result.items), result.total, noun="Belege", note=note)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("get")
|
|
100
|
+
def get_voucher(
|
|
101
|
+
ctx: typer.Context,
|
|
102
|
+
voucher: str = typer.Argument(..., help="UUID oder Belegnummer."),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Einen Beleg im Detail abrufen. Akzeptiert UUID oder Belegnummer."""
|
|
105
|
+
s = state(ctx)
|
|
106
|
+
with working("Lade Beleg …"):
|
|
107
|
+
data = services.get_voucher(s.client, voucher)
|
|
108
|
+
render(data, s.output_format, output_path=s.output_path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command("create-draft")
|
|
112
|
+
def create_draft(
|
|
113
|
+
ctx: typer.Context,
|
|
114
|
+
body: str = typer.Option(..., "--body", help="JSON-Body inline oder @datei.json."),
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Neuen Beleg anlegen."""
|
|
117
|
+
s = state(ctx)
|
|
118
|
+
payload = load_json_arg(body)
|
|
119
|
+
with working("Lege Beleg an …"):
|
|
120
|
+
result = services.create_voucher(s.client, payload)
|
|
121
|
+
render(result, s.output_format, output_path=s.output_path)
|