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
lxw_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
lxw_cli/__main__.py
ADDED
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)
|