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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
lxw_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from lxw_cli.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
lxw_cli/cli.py ADDED
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from lxw_cli import __version__
10
+ from lxw_cli.commands import (
11
+ articles,
12
+ contacts,
13
+ delivery_notes,
14
+ invoices,
15
+ orders,
16
+ quotations,
17
+ vouchers,
18
+ )
19
+ from lxw_cli.commands import (
20
+ mcp as mcp_cmd,
21
+ )
22
+ from lxw_cli.config import load_config_interactive
23
+ from lxw_cli.core import services
24
+ from lxw_cli.core.client import LexwareClient
25
+ from lxw_cli.core.errors import LexwareAPIError, LexwareError
26
+ from lxw_cli.output import OutputFormat, err_console, render, working
27
+
28
+ app = typer.Typer(
29
+ name="lxw",
30
+ help=(
31
+ "CLI für die Lexware Office API (https://developers.lexware.io/).\n\n"
32
+ "Liest Rechnungen, Kontakte, Belege, Artikel, Angebote und Lieferscheine "
33
+ "und legt Drafts/Stammdaten an. Globale Ausgabe-Optionen (--json, --csv, "
34
+ "--output) stehen vor dem Befehl; Detailhilfe je Befehl mit "
35
+ "'lxw <befehl> <unterbefehl> --help'."
36
+ ),
37
+ no_args_is_help=True,
38
+ rich_markup_mode="rich",
39
+ pretty_exceptions_show_locals=False,
40
+ epilog="""\
41
+ [bold cyan]Beispiele[/bold cyan]
42
+
43
+ Auth-Test (Firmenprofil): [green]lxw profile[/green]
44
+
45
+ Offene Rechnungen: [green]lxw invoices list --status open[/green]
46
+
47
+ Artikel suchen: [green]lxw articles list --search schraube[/green]
48
+
49
+ Kunden als CSV: [green]lxw --csv -o kunden.csv contacts list --customer --all[/green]
50
+
51
+ Claude-Integration einrichten: [green]lxw mcp install-claude[/green]
52
+ """,
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class AppState:
58
+ output_format: OutputFormat
59
+ output_path: Path | None
60
+ _client: LexwareClient | None = None
61
+
62
+ @property
63
+ def client(self) -> LexwareClient:
64
+ if self._client is None:
65
+ try:
66
+ config = load_config_interactive()
67
+ except LexwareError as exc:
68
+ err_console.print(f"[red]Fehler:[/red] {exc}")
69
+ raise typer.Exit(code=2) from exc
70
+ self._client = LexwareClient(config)
71
+ return self._client
72
+
73
+ def close(self) -> None:
74
+ if self._client is not None:
75
+ self._client.close()
76
+
77
+
78
+ def _version_callback(value: bool) -> None:
79
+ if value:
80
+ typer.echo(f"lxw-cli {__version__}")
81
+ raise typer.Exit()
82
+
83
+
84
+ @app.callback()
85
+ def main(
86
+ ctx: typer.Context,
87
+ json_out: bool = typer.Option(
88
+ False, "--json", help="JSON statt Tabelle ausgeben.", rich_help_panel="Output"
89
+ ),
90
+ csv_out: bool = typer.Option(
91
+ False, "--csv", help="CSV statt Tabelle ausgeben.", rich_help_panel="Output"
92
+ ),
93
+ output: Path | None = typer.Option(
94
+ None,
95
+ "--output",
96
+ "-o",
97
+ help="In Datei schreiben statt auf stdout.",
98
+ rich_help_panel="Output",
99
+ ),
100
+ _version: bool = typer.Option(
101
+ False,
102
+ "--version",
103
+ "-V",
104
+ callback=_version_callback,
105
+ is_eager=True,
106
+ help="Version anzeigen und beenden.",
107
+ ),
108
+ ) -> None:
109
+ if json_out and csv_out:
110
+ raise typer.BadParameter("--json und --csv schließen sich gegenseitig aus.")
111
+ fmt = (
112
+ OutputFormat.JSON
113
+ if json_out
114
+ else OutputFormat.CSV
115
+ if csv_out
116
+ else OutputFormat.TABLE
117
+ )
118
+
119
+ state = AppState(output_format=fmt, output_path=output)
120
+ ctx.obj = state
121
+ ctx.call_on_close(state.close)
122
+
123
+
124
+ @app.command("profile")
125
+ def profile_cmd(ctx: typer.Context) -> None:
126
+ """Firmenprofil abrufen (Auth-Test)."""
127
+ state: AppState = ctx.obj
128
+ with working("Lade Firmenprofil …"):
129
+ data = services.get_profile(state.client)
130
+ render(data, state.output_format, output_path=state.output_path)
131
+
132
+
133
+ @app.command("version")
134
+ def version_cmd() -> None:
135
+ """Version anzeigen."""
136
+ typer.echo(__version__)
137
+
138
+
139
+ app.add_typer(
140
+ invoices.app,
141
+ name="invoices",
142
+ help="Rechnungen: list/get/pdf/create-draft. Listen mit --status/--number/--all.",
143
+ )
144
+ app.add_typer(
145
+ contacts.app,
146
+ name="contacts",
147
+ help="Kontakte: list/get/create. Nur Kunden/Lieferanten mit --customer/--vendor.",
148
+ )
149
+ app.add_typer(
150
+ vouchers.app,
151
+ name="vouchers",
152
+ help="Belege: list/get/create-draft. Listen mit --type/--status/--number, --all.",
153
+ )
154
+ app.add_typer(
155
+ articles.app,
156
+ name="articles",
157
+ help="Artikel: list/get/create. Volltextsuche mit 'list --search/-q'.",
158
+ )
159
+ app.add_typer(
160
+ quotations.app, name="quotations", help="Angebote: list/get/pdf/create-draft, --all."
161
+ )
162
+ app.add_typer(
163
+ orders.app,
164
+ name="orders",
165
+ help="Aufträge (Auftragsbestätigungen): list/get/pdf/create-draft, --all.",
166
+ )
167
+ app.add_typer(
168
+ delivery_notes.app,
169
+ name="delivery-notes",
170
+ help="Lieferscheine: list/get/pdf/create-draft, --all.",
171
+ )
172
+ app.add_typer(mcp_cmd.app, name="mcp", help="MCP-Server-Integration für Claude.")
173
+
174
+
175
+ def _wants_tui(argv: list[str], *, stdin_tty: bool, stdout_tty: bool) -> bool:
176
+ """Decide whether the bare `lxw` invocation should open the TUI.
177
+
178
+ Only when there are no subcommands/arguments AND we're on a real interactive
179
+ terminal (both stdin and stdout). With arguments it's the CLI; when stdout
180
+ isn't a TTY (piping, scripting) it stays the CLI (Typer prints help) — never
181
+ the TUI, never an error.
182
+ """
183
+ return len(argv) <= 1 and stdin_tty and stdout_tty
184
+
185
+
186
+ def _run() -> None: # pragma: no cover
187
+ if _wants_tui(
188
+ sys.argv, stdin_tty=sys.stdin.isatty(), stdout_tty=sys.stdout.isatty()
189
+ ):
190
+ from lxw_cli.tui.app import run as run_tui
191
+
192
+ run_tui()
193
+ return
194
+ try:
195
+ app()
196
+ except LexwareAPIError as exc:
197
+ err_console.print(f"[red]API-Fehler:[/red] {exc}")
198
+ if isinstance(exc.body, dict) and exc.body:
199
+ err_console.print(exc.body)
200
+ raise SystemExit(1) from exc
201
+ except LexwareError as exc:
202
+ err_console.print(f"[red]Fehler:[/red] {exc}")
203
+ raise SystemExit(1) from exc
204
+
205
+
206
+ if __name__ == "__main__": # pragma: no cover
207
+ _run()
File without changes
@@ -0,0 +1,40 @@
1
+ """CLI glue shared by the command modules.
2
+
3
+ The UI-agnostic logic lives in :mod:`lxw_cli.core`. This module keeps only
4
+ the Typer-coupled helpers (``state``, ``load_json_arg``) and the archived-footer
5
+ display string.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import typer
15
+
16
+ if TYPE_CHECKING:
17
+ from lxw_cli.cli import AppState
18
+
19
+ # Footer hint shown when archived records are filtered out by default.
20
+ ARCHIVED_HINT = "ohne archivierte (--include-archived zeigt alle)"
21
+
22
+
23
+ def state(ctx: typer.Context) -> AppState:
24
+ return ctx.obj
25
+
26
+
27
+ def load_json_arg(value: str) -> Any:
28
+ """Load a JSON value either inline or from @path."""
29
+ if value.startswith("@"):
30
+ path = Path(value[1:]).expanduser()
31
+ try:
32
+ return json.loads(path.read_text(encoding="utf-8"))
33
+ except FileNotFoundError as exc:
34
+ raise typer.BadParameter(f"Datei nicht gefunden: {path}") from exc
35
+ except json.JSONDecodeError as exc:
36
+ raise typer.BadParameter(f"Ungültiges JSON in {path}: {exc}") from exc
37
+ try:
38
+ return json.loads(value)
39
+ except json.JSONDecodeError as exc:
40
+ raise typer.BadParameter(f"Ungültiges JSON: {exc}") from exc
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from lxw_cli.commands._common import load_json_arg, state
6
+ from lxw_cli.core import services
7
+ from lxw_cli.output import print_count, render, working
8
+
9
+ app = typer.Typer(no_args_is_help=True)
10
+
11
+ LIST_COLUMNS = ["id", "type", "title", "articleNumber", "gtin", "unitName"]
12
+
13
+
14
+ @app.command(
15
+ "list",
16
+ epilog="""\
17
+ [bold cyan]Beispiele[/bold cyan]
18
+
19
+ Volltextsuche nach Bezeichnung: [green]lxw articles list --search schraube[/green]
20
+
21
+ Teil-Artikelnummer: [green]lxw articles list -q SCH-[/green]
22
+
23
+ Exakte Artikelnummer (schnell): [green]lxw articles list --number SCH-001[/green]
24
+
25
+ Nur Dienstleistungen, alle: [green]lxw articles list --type service --all[/green]
26
+ """,
27
+ )
28
+ def list_articles(
29
+ ctx: typer.Context,
30
+ limit: int = typer.Option(
31
+ 25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
32
+ ),
33
+ fetch_all: bool = typer.Option(
34
+ False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
35
+ ),
36
+ search: str | None = typer.Option(
37
+ None,
38
+ "--search",
39
+ "-q",
40
+ help="Freitext in Bezeichnung, Beschreibung und Artikelnummer (Teiltreffer).",
41
+ ),
42
+ article_type: str | None = typer.Option(None, "--type", help="product|service"),
43
+ article_number: str | None = typer.Option(
44
+ None, "--number", help="Exakte Artikelnummer (serverseitig)."
45
+ ),
46
+ gtin: str | None = typer.Option(None, "--gtin", help="Exakte GTIN/EAN (serverseitig)."),
47
+ ) -> None:
48
+ """Artikel auflisten.
49
+
50
+ Zeigt standardmäßig bis zu 25 Artikel (--all bzw. --limit 0 lädt alle).
51
+ Die Lexware-API filtert nur exakt: --number (Artikelnummer), --gtin und
52
+ --type (product|service).
53
+
54
+ Für eine unscharfe Suche gibt es --search/-q: durchsucht clientseitig
55
+ Bezeichnung, Beschreibung und Artikelnummer nach Teiltreffern (Groß-/
56
+ Kleinschreibung egal) und zeigt am Ende die Trefferzahl.
57
+ """
58
+ s = state(ctx)
59
+ with working("Suche Artikel …" if search else "Lade Artikel …"):
60
+ result = services.list_articles(
61
+ s.client,
62
+ search=search,
63
+ article_type=article_type,
64
+ article_number=article_number,
65
+ gtin=gtin,
66
+ limit=limit,
67
+ fetch_all=fetch_all,
68
+ )
69
+ render(result.items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
70
+ if result.search:
71
+ # Client-side search: the API total counts all articles, not matches.
72
+ print_count(len(result.items), noun="Treffer")
73
+ else:
74
+ print_count(len(result.items), result.total, noun="Artikel")
75
+
76
+
77
+ @app.command("get")
78
+ def get_article(ctx: typer.Context, article_id: str) -> None:
79
+ """Einen Artikel im Detail abrufen."""
80
+ s = state(ctx)
81
+ with working("Lade Artikel …"):
82
+ data = services.get_article(s.client, article_id)
83
+ render(data, s.output_format, output_path=s.output_path)
84
+
85
+
86
+ @app.command(
87
+ "create",
88
+ epilog="""\
89
+ [bold cyan]Beispiele[/bold cyan]
90
+
91
+ Aus Datei: [green]lxw articles create --body @artikel.json[/green]
92
+
93
+ Inline-Minimal: [green]lxw articles create --body '{"type":"SERVICE","title":"…"}'[/green]
94
+ """,
95
+ )
96
+ def create(
97
+ ctx: typer.Context,
98
+ body: str = typer.Option(..., "--body", help="JSON-Body inline oder @datei.json."),
99
+ ) -> None:
100
+ """Neuen Artikel anlegen (Stammdaten, kein Draft).
101
+
102
+ Erwartet den JSON-Body der Lexware-API — inline oder als @pfad.json
103
+ (type PRODUCT|SERVICE, title, unitName, price …). Schema siehe
104
+ https://developers.lexware.io/docs/.
105
+ """
106
+ s = state(ctx)
107
+ payload = load_json_arg(body)
108
+ with working("Lege Artikel an …"):
109
+ result = services.create_article(s.client, payload)
110
+ render(result, s.output_format, output_path=s.output_path)
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+
7
+ from lxw_cli.commands._common import load_json_arg, state
8
+ from lxw_cli.core import services
9
+ from lxw_cli.output import OutputFormat, err_console, print_count, render, working
10
+
11
+ app = typer.Typer(no_args_is_help=True)
12
+
13
+ LIST_COLUMNS = ["id", "name", "role", "number", "email", "archived"]
14
+
15
+
16
+ @app.command(
17
+ "list",
18
+ epilog="""\
19
+ [bold cyan]Beispiele[/bold cyan]
20
+
21
+ Nur Kunden: [green]lxw contacts list --customer[/green]
22
+
23
+ Nur Lieferanten: [green]lxw contacts list --vendor[/green]
24
+
25
+ Nach Name suchen: [green]lxw contacts list --name Müller[/green]
26
+
27
+ Getrennt nach Rolle: [green]lxw contacts list --grouped[/green]
28
+
29
+ Inkl. archivierte (CSV): [green]lxw --csv contacts list --all --include-archived[/green]
30
+ """,
31
+ )
32
+ def list_contacts(
33
+ ctx: typer.Context,
34
+ limit: int = typer.Option(
35
+ 25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
36
+ ),
37
+ fetch_all: bool = typer.Option(
38
+ False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
39
+ ),
40
+ name: str | None = typer.Option(None, "--name", help="Filter nach Name (min. 3 Zeichen)."),
41
+ email: str | None = typer.Option(None, "--email", help="Filter nach E-Mail (min. 3 Zeichen)."),
42
+ number: str | None = typer.Option(None, "--number", help="Kunden-/Lieferantennummer."),
43
+ customer: bool = typer.Option(False, "--customer", help="Nur Kunden."),
44
+ vendor: bool = typer.Option(False, "--vendor", help="Nur Lieferanten."),
45
+ grouped: bool = typer.Option(
46
+ False,
47
+ "--grouped/--flat",
48
+ help="Tabelle nach Kunden/Lieferanten gruppieren (Standard: aus).",
49
+ ),
50
+ include_archived: bool = typer.Option(
51
+ False,
52
+ "--include-archived",
53
+ help="Auch archivierte Kontakte anzeigen (Standard: ausgeblendet).",
54
+ ),
55
+ ) -> None:
56
+ """Kontakte auflisten.
57
+
58
+ Zeigt standardmäßig bis zu 25 Kontakte als eine zusammenhängende Liste
59
+ (--all bzw. --limit 0 lädt alle). Filtern nach Name/E-Mail (je min. 3
60
+ Zeichen), Nummer oder Rolle: --customer zeigt nur Kunden, --vendor nur
61
+ Lieferanten.
62
+
63
+ Mit --grouped wird die Tabelle in zwei Abschnitte geteilt (zuerst Kunden,
64
+ dann Lieferanten, je mit passender Nummer). Archivierte Kontakte sind
65
+ standardmäßig ausgeblendet; --include-archived zeigt sie. JSON/CSV sind
66
+ immer eine flache Liste mit role-Spalte.
67
+ """
68
+ s = state(ctx)
69
+ with working("Lade Kontakte …"):
70
+ result = services.list_contacts(
71
+ s.client,
72
+ name=name,
73
+ email=email,
74
+ number=number,
75
+ customer=customer,
76
+ vendor=vendor,
77
+ include_archived=include_archived,
78
+ limit=limit,
79
+ fetch_all=fetch_all,
80
+ )
81
+ raw = result.items
82
+
83
+ if grouped and s.output_format is OutputFormat.TABLE:
84
+ _render_grouped(raw)
85
+ else:
86
+ items = [_flatten_contact(c) for c in raw]
87
+ render(items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
88
+
89
+ if include_archived:
90
+ print_count(len(raw), result.total, noun="Kontakte")
91
+ elif result.exhausted:
92
+ suffix = f" ({result.hidden} archivierte ausgeblendet)" if result.hidden else ""
93
+ err_console.print(
94
+ f"[dim]→ {len(raw)} von {len(raw)} aktiven Kontakten{suffix}[/dim]"
95
+ )
96
+ else:
97
+ err_console.print(
98
+ f"[dim]→ {len(raw)} aktive Kontakte angezeigt "
99
+ "(archivierte ausgeblendet · --all für Gesamtzahl, "
100
+ "--include-archived zeigt alle)[/dim]"
101
+ )
102
+
103
+
104
+ def _render_grouped(raw: list[Any]) -> None:
105
+ """Render contacts as separate Kunden / Lieferanten tables.
106
+
107
+ A contact with both roles appears in both tables (its `role` column shows
108
+ 'customer+vendor'), and each table shows the number for its own role.
109
+ Contacts without any role land in a trailing 'Ohne Rolle' table.
110
+ """
111
+ sections = (("Kunden", "customer"), ("Lieferanten", "vendor"))
112
+ rendered = False
113
+ for title, role in sections:
114
+ rows = [
115
+ _flatten_contact(c, number_role=role)
116
+ for c in raw
117
+ if role in (c.get("roles") or {})
118
+ ]
119
+ if rows:
120
+ render(rows, OutputFormat.TABLE, columns=LIST_COLUMNS, title=title)
121
+ rendered = True
122
+ others = [_flatten_contact(c) for c in raw if not (c.get("roles") or {})]
123
+ if others:
124
+ render(others, OutputFormat.TABLE, columns=LIST_COLUMNS, title="Ohne Rolle")
125
+ rendered = True
126
+ if not rendered:
127
+ render([], OutputFormat.TABLE, columns=LIST_COLUMNS)
128
+
129
+
130
+ @app.command("get")
131
+ def get_contact(ctx: typer.Context, contact_id: str) -> None:
132
+ """Einen Kontakt im Detail abrufen."""
133
+ s = state(ctx)
134
+ with working("Lade Kontakt …"):
135
+ data = services.get_contact(s.client, contact_id)
136
+ render(data, s.output_format, output_path=s.output_path)
137
+
138
+
139
+ @app.command(
140
+ "create",
141
+ epilog="""\
142
+ [bold cyan]Beispiele[/bold cyan]
143
+
144
+ Kunde aus Datei: [green]lxw contacts create --body @kunde.json[/green]
145
+
146
+ Lieferant aus Datei: [green]lxw contacts create --body @lieferant.json[/green]
147
+ """,
148
+ )
149
+ def create(
150
+ ctx: typer.Context,
151
+ body: str = typer.Option(..., "--body", help="JSON-Body inline oder @datei.json."),
152
+ ) -> None:
153
+ """Neuen Kontakt anlegen (Stammdaten, kein Draft).
154
+
155
+ Erwartet den JSON-Body der Lexware-API — inline oder als @pfad.json. Mit
156
+ 'roles' wird die Rolle gesetzt: {"customer":{}} und/oder {"vendor":{}}.
157
+ Minimal: {"roles":{"customer":{}},"company":{"name":"…"}}.
158
+ """
159
+ s = state(ctx)
160
+ payload = load_json_arg(body)
161
+ with working("Lege Kontakt an …"):
162
+ result = services.create_contact(s.client, payload)
163
+ render(result, s.output_format, output_path=s.output_path)
164
+
165
+
166
+ def _flatten_contact(
167
+ c: dict[str, Any], number_role: str | None = None
168
+ ) -> dict[str, Any]:
169
+ """Project nested contact fields into a flat row for table/CSV display.
170
+
171
+ `number_role` ('customer' or 'vendor') picks which role's number to show —
172
+ used by the grouped view so the Lieferanten table shows vendor numbers and
173
+ the Kunden table shows customer numbers. When None, the customer number is
174
+ preferred, then the vendor number.
175
+ """
176
+ company = c.get("company") or {}
177
+ person = c.get("person") or {}
178
+ roles = c.get("roles") or {}
179
+ emails = (c.get("emailAddresses") or {}).get("business") or []
180
+
181
+ if company.get("name"):
182
+ name = company["name"]
183
+ elif person:
184
+ name = " ".join(
185
+ p for p in (person.get("firstName"), person.get("lastName")) if p
186
+ ).strip()
187
+ else:
188
+ name = ""
189
+
190
+ if "customer" in roles and "vendor" in roles:
191
+ role = "customer+vendor"
192
+ elif "customer" in roles:
193
+ role = "customer"
194
+ elif "vendor" in roles:
195
+ role = "vendor"
196
+ else:
197
+ role = ""
198
+
199
+ if number_role is not None:
200
+ number = (roles.get(number_role) or {}).get("number") or ""
201
+ else:
202
+ number = (
203
+ (roles.get("customer") or {}).get("number")
204
+ or (roles.get("vendor") or {}).get("number")
205
+ or ""
206
+ )
207
+
208
+ return {
209
+ "id": c.get("id"),
210
+ "name": name,
211
+ "role": role,
212
+ "number": number,
213
+ "email": emails[0] if emails else "",
214
+ "archived": c.get("archived"),
215
+ }
@@ -0,0 +1,114 @@
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
+ ]
21
+
22
+
23
+ @app.command(
24
+ "list",
25
+ epilog="""\
26
+ [bold cyan]Beispiele[/bold cyan]
27
+
28
+ Alle Lieferscheine: [green]lxw delivery-notes list --all[/green]
29
+
30
+ Als CSV exportieren: [green]lxw --csv -o lieferscheine.csv delivery-notes list --all[/green]
31
+
32
+ Inkl. archivierte: [green]lxw delivery-notes list --include-archived[/green]
33
+ """,
34
+ )
35
+ def list_delivery_notes(
36
+ ctx: typer.Context,
37
+ limit: int = typer.Option(
38
+ 25, "--limit", "-n", help="Maximale Anzahl Einträge (0 = alle)."
39
+ ),
40
+ fetch_all: bool = typer.Option(
41
+ False, "--all", "-a", help="Alle Treffer laden (überschreibt --limit)."
42
+ ),
43
+ voucher_status: str = typer.Option(
44
+ DEFAULT_VOUCHER_STATUSES,
45
+ "--status",
46
+ help="Komma-separiert (z.B. draft,open,paid).",
47
+ ),
48
+ include_archived: bool = typer.Option(
49
+ False,
50
+ "--include-archived",
51
+ help="Auch archivierte Lieferscheine anzeigen (Standard: ausgeblendet).",
52
+ ),
53
+ ) -> None:
54
+ """Lieferscheine auflisten (über /v1/voucherlist).
55
+
56
+ Standardmäßig bis zu 25 Lieferscheine (--all bzw. --limit 0 lädt alle),
57
+ filterbar nach Status. Archivierte sind standardmäßig ausgeblendet;
58
+ --include-archived zeigt sie. get/pdf akzeptieren UUID oder Nummer.
59
+ """
60
+ s = state(ctx)
61
+ with working("Lade Lieferscheine …"):
62
+ result = services.list_delivery_notes(
63
+ s.client,
64
+ status=voucher_status,
65
+ include_archived=include_archived,
66
+ limit=limit,
67
+ fetch_all=fetch_all,
68
+ )
69
+ render(result.items, s.output_format, columns=LIST_COLUMNS, output_path=s.output_path)
70
+ note = None if include_archived else ARCHIVED_HINT
71
+ print_count(len(result.items), result.total, noun="Lieferscheine", note=note)
72
+
73
+
74
+ @app.command("get")
75
+ def get_delivery_note(
76
+ ctx: typer.Context,
77
+ delivery_note: str = typer.Argument(..., help="UUID oder Lieferschein-Nummer."),
78
+ ) -> None:
79
+ """Einen Lieferschein im Detail abrufen. Akzeptiert UUID oder Nummer."""
80
+ s = state(ctx)
81
+ with working("Lade Lieferschein …"):
82
+ data = services.get_delivery_note(s.client, delivery_note)
83
+ render(data, s.output_format, output_path=s.output_path)
84
+
85
+
86
+ @app.command("pdf")
87
+ def download_pdf(
88
+ ctx: typer.Context,
89
+ delivery_note: str = typer.Argument(..., help="UUID oder Lieferschein-Nummer."),
90
+ output: Path | None = typer.Option(
91
+ None,
92
+ "--output",
93
+ "-o",
94
+ help="Ziel-Datei oder -Verzeichnis (Standard: aktuelles Verzeichnis).",
95
+ ),
96
+ ) -> None:
97
+ """PDF eines Lieferscheins herunterladen. Akzeptiert UUID oder Nummer."""
98
+ s = state(ctx)
99
+ with working("Lade PDF …"):
100
+ data = services.download_delivery_note_pdf(s.client, delivery_note)
101
+ write_binary(data, output, default_name=f"deliverynote-{delivery_note}.pdf")
102
+
103
+
104
+ @app.command("create-draft")
105
+ def create_draft(
106
+ ctx: typer.Context,
107
+ body: str = typer.Option(..., "--body", help="JSON-Body inline oder @datei.json."),
108
+ ) -> None:
109
+ """Neuen Lieferschein als Draft anlegen."""
110
+ s = state(ctx)
111
+ payload = load_json_arg(body)
112
+ with working("Lege Lieferschein an …"):
113
+ result = services.create_delivery_note(s.client, payload)
114
+ render(result, s.output_format, output_path=s.output_path)