her-sync 0.3.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.
- her_sync/__init__.py +3 -0
- her_sync/__main__.py +3 -0
- her_sync/apply/__init__.py +8 -0
- her_sync/apply/cost_centers.py +84 -0
- her_sync/apply/invoices.py +77 -0
- her_sync/bde/__init__.py +11 -0
- her_sync/bde/cashflows.py +150 -0
- her_sync/bde/cost_centers.py +22 -0
- her_sync/bde/invoices.py +199 -0
- her_sync/bde/session.py +124 -0
- her_sync/cli.py +407 -0
- her_sync/cli_helpers.py +10 -0
- her_sync/collectors/__init__.py +10 -0
- her_sync/collectors/cashflows.py +166 -0
- her_sync/collectors/invoices.py +70 -0
- her_sync/collectors/receipts.py +30 -0
- her_sync/config.py +153 -0
- her_sync/daemon.py +102 -0
- her_sync/io/paths.py +69 -0
- her_sync/io/xlsx.py +51 -0
- her_sync/migrate.py +252 -0
- her_sync/plugins/__init__.py +1 -0
- her_sync/plugins/bde.py +180 -0
- her_sync/plugins/caldav.py +124 -0
- her_sync/plugins/email.py +59 -0
- her_sync/profile.py +275 -0
- her_sync/secrets.py +92 -0
- her_sync/sync/__init__.py +3 -0
- her_sync/sync/caldav_client.py +199 -0
- her_sync/sync/calendar.py +287 -0
- her_sync/sync/calendar_filters.py +53 -0
- her_sync/sync/calendar_priority.py +59 -0
- her_sync/sync/email.py +498 -0
- her_sync/sync/email_filters.py +77 -0
- her_sync/sync/email_watcher.py +151 -0
- her_sync/sync/gloda_index.py +63 -0
- her_sync/sync/mbox_reader.py +163 -0
- her_sync/sync/msf_index.py +279 -0
- her_sync/sync/sync_lock.py +28 -0
- her_sync/tools/browser_record.py +76 -0
- her_sync-0.3.0.dist-info/METADATA +115 -0
- her_sync-0.3.0.dist-info/RECORD +44 -0
- her_sync-0.3.0.dist-info/WHEEL +4 -0
- her_sync-0.3.0.dist-info/entry_points.txt +2 -0
her_sync/cli.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""her CLI (her-sync)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
import yaml
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from her_sync.cli_helpers import group_typer
|
|
17
|
+
from her_sync.config import (
|
|
18
|
+
PluginDisabledError,
|
|
19
|
+
get_profile,
|
|
20
|
+
load_bde_settings,
|
|
21
|
+
load_caldav_settings,
|
|
22
|
+
load_email_settings,
|
|
23
|
+
runtime_context,
|
|
24
|
+
)
|
|
25
|
+
from her_sync.daemon import run_daemon
|
|
26
|
+
from her_sync.migrate import migrate_from_env
|
|
27
|
+
from her_sync.plugins import bde, caldav, email
|
|
28
|
+
from her_sync.profile import (
|
|
29
|
+
CONFIG_DIR,
|
|
30
|
+
config_file,
|
|
31
|
+
init_profile,
|
|
32
|
+
list_profiles,
|
|
33
|
+
load_profile,
|
|
34
|
+
)
|
|
35
|
+
from her_sync.secrets import (
|
|
36
|
+
SecretError,
|
|
37
|
+
delete_secret,
|
|
38
|
+
has_secret,
|
|
39
|
+
migrate_legacy_keyring,
|
|
40
|
+
set_secret,
|
|
41
|
+
)
|
|
42
|
+
from her_sync.share.layout import ShareLayout
|
|
43
|
+
|
|
44
|
+
app = typer.Typer(
|
|
45
|
+
name="her",
|
|
46
|
+
help="Linux-native Sync-Tool für Agent-Share-Filesysteme.",
|
|
47
|
+
no_args_is_help=True,
|
|
48
|
+
)
|
|
49
|
+
profile_app = group_typer(help="Profile verwalten")
|
|
50
|
+
secrets_app = group_typer(help="Secrets im System-Keyring")
|
|
51
|
+
daemon_app = group_typer(help="Watcher-Daemon")
|
|
52
|
+
|
|
53
|
+
app.add_typer(profile_app, name="profile")
|
|
54
|
+
app.add_typer(secrets_app, name="secrets")
|
|
55
|
+
app.add_typer(daemon_app, name="daemon")
|
|
56
|
+
app.add_typer(bde.app, name="bde")
|
|
57
|
+
app.add_typer(email.app, name="email")
|
|
58
|
+
app.add_typer(caldav.app, name="caldav")
|
|
59
|
+
|
|
60
|
+
console = Console()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.callback()
|
|
64
|
+
def main(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
profile: str | None = typer.Option(
|
|
67
|
+
None,
|
|
68
|
+
"--profile",
|
|
69
|
+
"-p",
|
|
70
|
+
help="Profilname (Default: ~/.her/config.yml)",
|
|
71
|
+
envvar="HER_PROFILE",
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
ctx.ensure_object(dict)
|
|
75
|
+
ctx.obj["profile"] = profile
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@profile_app.command("list")
|
|
79
|
+
def profile_list() -> None:
|
|
80
|
+
"""Alle Profile unter ~/.her/profiles/ auflisten."""
|
|
81
|
+
names = list_profiles()
|
|
82
|
+
if not names:
|
|
83
|
+
console.print(f"[dim]Keine Profile in {CONFIG_DIR / 'profiles'}[/]")
|
|
84
|
+
return
|
|
85
|
+
default = None
|
|
86
|
+
if config_file().exists():
|
|
87
|
+
default = (yaml.safe_load(config_file().read_text()) or {}).get("default_profile")
|
|
88
|
+
for name in names:
|
|
89
|
+
mark = " (default)" if name == default else ""
|
|
90
|
+
console.print(f" {name}{mark}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@profile_app.command("show")
|
|
94
|
+
def profile_show(name: str) -> None:
|
|
95
|
+
"""Profil-Konfiguration als YAML anzeigen."""
|
|
96
|
+
profile = load_profile(name)
|
|
97
|
+
console.print(yaml.safe_dump({"name": profile.name, **profile.as_dict()}, allow_unicode=True))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@profile_app.command("init")
|
|
101
|
+
def profile_init(
|
|
102
|
+
name: str,
|
|
103
|
+
share: Path | None = typer.Option(None, help="Share-Zielverzeichnis"),
|
|
104
|
+
force: bool = typer.Option(False, "--force"),
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Neues Profil mit Vorlage anlegen."""
|
|
107
|
+
path = init_profile(name, share=share, force=force)
|
|
108
|
+
console.print(f"[green]Profil angelegt:[/] {path}")
|
|
109
|
+
console.print(f"[dim]Secrets setzen: her secrets set {name}/bde-password[/]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@profile_app.command("migrate-env")
|
|
113
|
+
def profile_migrate_env(
|
|
114
|
+
name: str = typer.Option("bde-work", "--name", "-n", help="Profilname"),
|
|
115
|
+
env_file: Path = typer.Option(
|
|
116
|
+
Path(".env"),
|
|
117
|
+
"--env-file",
|
|
118
|
+
help="Pfad zur Legacy-.env",
|
|
119
|
+
),
|
|
120
|
+
store_secrets: bool = typer.Option(
|
|
121
|
+
True,
|
|
122
|
+
"--store-secrets/--plain-secrets",
|
|
123
|
+
help="Passwörter in Keyring (empfohlen) vs. Klartext in profile.yml",
|
|
124
|
+
),
|
|
125
|
+
force: bool = typer.Option(False, "--force", help="Bestehendes Profil überschreiben"),
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Legacy-.env nach ~/.her/profiles/NAME/ migrieren."""
|
|
128
|
+
try:
|
|
129
|
+
result = migrate_from_env(
|
|
130
|
+
env_file,
|
|
131
|
+
name,
|
|
132
|
+
store_secrets=store_secrets,
|
|
133
|
+
force=force,
|
|
134
|
+
)
|
|
135
|
+
except FileExistsError as exc:
|
|
136
|
+
console.print(f"[red]{exc}[/]")
|
|
137
|
+
raise typer.Exit(code=1) from exc
|
|
138
|
+
|
|
139
|
+
console.print(f"[green]Profil migriert:[/] {result.profile_path}")
|
|
140
|
+
console.print(f" Share: {result.share_root}")
|
|
141
|
+
if result.secrets_stored:
|
|
142
|
+
console.print(f" Keyring: {', '.join(result.secrets_stored)}")
|
|
143
|
+
for path in result.filters_copied:
|
|
144
|
+
console.print(f" Filter kopiert: {path}")
|
|
145
|
+
if result.cookies_migrated:
|
|
146
|
+
console.print(" BDE-Cookies migriert → state/")
|
|
147
|
+
for warning in result.warnings:
|
|
148
|
+
console.print(f"[yellow]Warnung:[/] {warning}")
|
|
149
|
+
console.print(f"[dim]Test: her --profile {name} status[/]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@profile_app.command("validate")
|
|
153
|
+
def profile_validate(name: str | None = typer.Argument(None)) -> None:
|
|
154
|
+
"""Profil laden und Secrets prüfen."""
|
|
155
|
+
profile = load_profile(name)
|
|
156
|
+
table = Table(title=f"Profil {profile.name}")
|
|
157
|
+
table.add_column("Check")
|
|
158
|
+
table.add_column("Status")
|
|
159
|
+
|
|
160
|
+
table.add_row("config", f"{len(profile.config_sources)} YAML-Fragmente")
|
|
161
|
+
for source in profile.config_sources:
|
|
162
|
+
table.add_row(f" {source.name}", "OK")
|
|
163
|
+
table.add_row("share", str(profile.share_root))
|
|
164
|
+
|
|
165
|
+
for plugin in ("bde", "email", "caldav"):
|
|
166
|
+
if plugin not in profile.plugin_configs:
|
|
167
|
+
table.add_row(f"plugin:{plugin}", "[dim]disabled (kein Key)[/]")
|
|
168
|
+
continue
|
|
169
|
+
enabled = profile.is_enabled(plugin)
|
|
170
|
+
table.add_row(f"plugin:{plugin}", "enabled" if enabled else "disabled")
|
|
171
|
+
|
|
172
|
+
def _collect_keyring_refs(obj: object) -> list[str]:
|
|
173
|
+
refs: list[str] = []
|
|
174
|
+
if isinstance(obj, dict):
|
|
175
|
+
ref = obj.get("keyring")
|
|
176
|
+
if isinstance(ref, str):
|
|
177
|
+
refs.append(ref)
|
|
178
|
+
for value in obj.values():
|
|
179
|
+
refs.extend(_collect_keyring_refs(value))
|
|
180
|
+
elif isinstance(obj, list):
|
|
181
|
+
for item in obj:
|
|
182
|
+
refs.extend(_collect_keyring_refs(item))
|
|
183
|
+
return refs
|
|
184
|
+
|
|
185
|
+
loaders = {
|
|
186
|
+
"bde": load_bde_settings,
|
|
187
|
+
"email": load_email_settings,
|
|
188
|
+
"caldav": load_caldav_settings,
|
|
189
|
+
}
|
|
190
|
+
failed = False
|
|
191
|
+
for plugin, loader in loaders.items():
|
|
192
|
+
if not profile.is_enabled(plugin):
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
loader(profile.name)
|
|
196
|
+
table.add_row(f"load:{plugin}", "[green]OK[/]")
|
|
197
|
+
except SecretError as exc:
|
|
198
|
+
table.add_row(f"load:{plugin}", f"[red]{exc}[/]")
|
|
199
|
+
failed = True
|
|
200
|
+
except PluginDisabledError:
|
|
201
|
+
table.add_row(f"load:{plugin}", "[dim]disabled[/]")
|
|
202
|
+
|
|
203
|
+
if profile.is_enabled("email"):
|
|
204
|
+
email_filters = profile.plugin_config("email").get("filters")
|
|
205
|
+
if isinstance(email_filters, list):
|
|
206
|
+
table.add_row("email_filters", f"{len(email_filters)} inline")
|
|
207
|
+
elif isinstance(email_filters, str):
|
|
208
|
+
path = profile.profile_dir / email_filters
|
|
209
|
+
table.add_row("email_filters", "OK" if path.exists() else "[red]fehlt[/]")
|
|
210
|
+
else:
|
|
211
|
+
table.add_row("email_filters", "[red]fehlt[/]")
|
|
212
|
+
|
|
213
|
+
if profile.is_enabled("caldav"):
|
|
214
|
+
caldav = profile.plugin_config("caldav")
|
|
215
|
+
if "calendars" in caldav:
|
|
216
|
+
table.add_row("calendar_filters", f"{len(caldav.get('calendars', []))} inline")
|
|
217
|
+
elif isinstance(caldav.get("filters"), str):
|
|
218
|
+
path = profile.profile_dir / caldav["filters"]
|
|
219
|
+
table.add_row("calendar_filters", "OK" if path.exists() else "[red]fehlt[/]")
|
|
220
|
+
else:
|
|
221
|
+
table.add_row("calendar_filters", "[red]fehlt[/]")
|
|
222
|
+
|
|
223
|
+
console.print(table)
|
|
224
|
+
if failed:
|
|
225
|
+
raise typer.Exit(code=1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@secrets_app.command("set")
|
|
229
|
+
def secrets_set(ref: str) -> None:
|
|
230
|
+
"""Secret im Keyring speichern (Passwort interaktiv)."""
|
|
231
|
+
value = typer.prompt("Wert", hide_input=True)
|
|
232
|
+
set_secret(ref, value)
|
|
233
|
+
console.print(f"[green]Gespeichert:[/] her-sync / {ref}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@secrets_app.command("delete")
|
|
237
|
+
def secrets_delete(ref: str) -> None:
|
|
238
|
+
"""Secret aus dem Keyring entfernen."""
|
|
239
|
+
if delete_secret(ref):
|
|
240
|
+
console.print(f"[green]Gelöscht:[/] {ref}")
|
|
241
|
+
else:
|
|
242
|
+
console.print(f"[yellow]Nicht gefunden:[/] {ref}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@secrets_app.command("test")
|
|
246
|
+
def secrets_test(ref: str) -> None:
|
|
247
|
+
"""Prüfen ob ein Keyring-Eintrag existiert."""
|
|
248
|
+
if has_secret(ref):
|
|
249
|
+
console.print(f"[green]OK[/] {ref}")
|
|
250
|
+
else:
|
|
251
|
+
console.print(f"[red]Fehlt[/] {ref}")
|
|
252
|
+
raise typer.Exit(code=1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@secrets_app.command("migrate-legacy")
|
|
256
|
+
def secrets_migrate_legacy(ctx: typer.Context) -> None:
|
|
257
|
+
"""Keyring-Einträge von agent-sync nach her-sync kopieren (einmalig)."""
|
|
258
|
+
profile = load_profile(ctx.obj.get("profile") if ctx.obj else None)
|
|
259
|
+
|
|
260
|
+
def _refs(obj: object) -> list[str]:
|
|
261
|
+
refs: list[str] = []
|
|
262
|
+
if isinstance(obj, dict):
|
|
263
|
+
ref = obj.get("keyring")
|
|
264
|
+
if isinstance(ref, str):
|
|
265
|
+
refs.append(ref)
|
|
266
|
+
for value in obj.values():
|
|
267
|
+
refs.extend(_refs(value))
|
|
268
|
+
elif isinstance(obj, list):
|
|
269
|
+
for item in obj:
|
|
270
|
+
refs.extend(_refs(item))
|
|
271
|
+
return refs
|
|
272
|
+
|
|
273
|
+
refs = sorted(set(_refs(profile.raw)))
|
|
274
|
+
migrated = migrate_legacy_keyring(refs)
|
|
275
|
+
if migrated:
|
|
276
|
+
for ref in migrated:
|
|
277
|
+
console.print(f"[green]Migriert:[/] {ref}")
|
|
278
|
+
else:
|
|
279
|
+
console.print("[dim]Nichts zu migrieren[/]")
|
|
280
|
+
missing = [r for r in refs if not has_secret(r)]
|
|
281
|
+
if missing:
|
|
282
|
+
console.print(f"[yellow]Fehlen noch:[/] {', '.join(missing)}")
|
|
283
|
+
console.print("[dim]Setzen: her secrets set REF[/]")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _pid_file(profile_name: str) -> Path:
|
|
287
|
+
return runtime_context(load_profile(profile_name)).state_dir / "daemon.pid"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@daemon_app.command("run")
|
|
291
|
+
def daemon_run(
|
|
292
|
+
ctx: typer.Context,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Daemon im Vordergrund (für systemd oder Debugging)."""
|
|
295
|
+
profile = ctx.obj.get("profile") if ctx.obj else None
|
|
296
|
+
pid_path = _pid_file(profile or load_profile(None).name)
|
|
297
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
299
|
+
try:
|
|
300
|
+
run_daemon(profile)
|
|
301
|
+
finally:
|
|
302
|
+
pid_path.unlink(missing_ok=True)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@daemon_app.command("status")
|
|
306
|
+
def daemon_status(ctx: typer.Context) -> None:
|
|
307
|
+
"""Daemon-Status für das Profil anzeigen."""
|
|
308
|
+
profile = ctx.obj.get("profile") if ctx.obj else None
|
|
309
|
+
profile_name = profile or load_profile(None).name
|
|
310
|
+
pid_path = _pid_file(profile_name)
|
|
311
|
+
if not pid_path.exists():
|
|
312
|
+
console.print(f"[dim]Daemon nicht aktiv ({profile_name})[/]")
|
|
313
|
+
return
|
|
314
|
+
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
|
315
|
+
try:
|
|
316
|
+
os.kill(pid, 0)
|
|
317
|
+
console.print(f"[green]Läuft[/] PID {pid} (Profil {profile_name})")
|
|
318
|
+
except OSError:
|
|
319
|
+
console.print(f"[yellow]Stale PID[/] {pid_path}")
|
|
320
|
+
pid_path.unlink(missing_ok=True)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@daemon_app.command("stop")
|
|
324
|
+
def daemon_stop(ctx: typer.Context) -> None:
|
|
325
|
+
"""Laufenden Daemon beenden."""
|
|
326
|
+
profile = ctx.obj.get("profile") if ctx.obj else None
|
|
327
|
+
profile_name = profile or load_profile(None).name
|
|
328
|
+
pid_path = _pid_file(profile_name)
|
|
329
|
+
if not pid_path.exists():
|
|
330
|
+
console.print("[dim]Daemon nicht aktiv[/]")
|
|
331
|
+
return
|
|
332
|
+
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
|
333
|
+
os.kill(pid, signal.SIGTERM)
|
|
334
|
+
pid_path.unlink(missing_ok=True)
|
|
335
|
+
console.print(f"[green]Gestoppt[/] PID {pid}")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@daemon_app.command("start")
|
|
339
|
+
def daemon_start(ctx: typer.Context) -> None:
|
|
340
|
+
"""Daemon im Hintergrund starten."""
|
|
341
|
+
profile = ctx.obj.get("profile") if ctx.obj else None
|
|
342
|
+
profile_name = profile or load_profile(None).name
|
|
343
|
+
pid_path = _pid_file(profile_name)
|
|
344
|
+
if pid_path.exists():
|
|
345
|
+
try:
|
|
346
|
+
pid = int(pid_path.read_text(encoding="utf-8").strip())
|
|
347
|
+
os.kill(pid, 0)
|
|
348
|
+
console.print(f"[yellow]Läuft bereits[/] PID {pid}")
|
|
349
|
+
return
|
|
350
|
+
except OSError:
|
|
351
|
+
pid_path.unlink(missing_ok=True)
|
|
352
|
+
|
|
353
|
+
cmd = [
|
|
354
|
+
sys.executable,
|
|
355
|
+
"-m",
|
|
356
|
+
"her_sync.cli",
|
|
357
|
+
"daemon",
|
|
358
|
+
"run",
|
|
359
|
+
"--profile",
|
|
360
|
+
profile_name,
|
|
361
|
+
]
|
|
362
|
+
subprocess.Popen(
|
|
363
|
+
cmd,
|
|
364
|
+
stdout=subprocess.DEVNULL,
|
|
365
|
+
stderr=subprocess.DEVNULL,
|
|
366
|
+
start_new_session=True,
|
|
367
|
+
)
|
|
368
|
+
console.print(f"[green]Daemon gestartet[/] (Profil {profile_name})")
|
|
369
|
+
console.print("[dim]Für Autostart: systemd user unit her@{profile}[/]")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.command("status")
|
|
373
|
+
def status_cmd(ctx: typer.Context) -> None:
|
|
374
|
+
"""Share-FS Status für das Profil."""
|
|
375
|
+
profile = ctx.obj.get("profile") if ctx.obj else None
|
|
376
|
+
prof = load_profile(profile)
|
|
377
|
+
ctx = runtime_context(prof)
|
|
378
|
+
layout = ShareLayout(ctx.share_root)
|
|
379
|
+
|
|
380
|
+
console.print(f"[bold]Profil:[/] {ctx.profile_name}")
|
|
381
|
+
console.print(f"[bold]Share:[/] {ctx.share_root}")
|
|
382
|
+
|
|
383
|
+
table = Table(title="Share-FS")
|
|
384
|
+
table.add_column("Pfad")
|
|
385
|
+
table.add_column("Status")
|
|
386
|
+
checks = [
|
|
387
|
+
(layout.cashflows_unassigned, "Unzugeordnete Buchungen"),
|
|
388
|
+
(layout.cashflows_history, "Kostenstellen-Muster"),
|
|
389
|
+
(layout.cost_centers, "Kostenstellen"),
|
|
390
|
+
(layout.open_invoices, "Offene Rechnungen"),
|
|
391
|
+
(layout.receipts, "Belege"),
|
|
392
|
+
(layout.extract_results, "Agent-Ergebnisse"),
|
|
393
|
+
(layout.calendar, "Kalender"),
|
|
394
|
+
(layout.emails, "E-Mails"),
|
|
395
|
+
]
|
|
396
|
+
for path, label in checks:
|
|
397
|
+
if path.is_dir():
|
|
398
|
+
table.add_row(label, f"{len(list(path.iterdir()))} Einträge")
|
|
399
|
+
elif path.exists():
|
|
400
|
+
table.add_row(label, "vorhanden")
|
|
401
|
+
else:
|
|
402
|
+
table.add_row(label, "[dim]fehlt[/]")
|
|
403
|
+
console.print(table)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
app()
|
her_sync/cli_helpers.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Gemeinsame CLI-Hilfen für Typer-Gruppen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def group_typer(*, help: str) -> typer.Typer:
|
|
9
|
+
"""Typer-Gruppe — ohne Subbefehl automatisch Hilfe anzeigen."""
|
|
10
|
+
return typer.Typer(help=help, no_args_is_help=True)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from her_sync.collectors.cashflows import collect_cashflow_history, collect_cashflows
|
|
2
|
+
from her_sync.collectors.invoices import collect_open_invoices
|
|
3
|
+
from her_sync.collectors.receipts import collect_receipts
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"collect_cashflow_history",
|
|
7
|
+
"collect_cashflows",
|
|
8
|
+
"collect_open_invoices",
|
|
9
|
+
"collect_receipts",
|
|
10
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Cashflow-Daten aus BDE sammeln und ins Share-FS schreiben."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from her_sync.bde.cashflows import CashflowService
|
|
9
|
+
from her_sync.bde.cost_centers import load_cost_center_map
|
|
10
|
+
from her_sync.bde.invoices import InvoiceService
|
|
11
|
+
from her_sync.bde.session import BdeSession
|
|
12
|
+
from her_sync.config import BdeSettings
|
|
13
|
+
from her_sync.io.xlsx import read_rows, write_rows
|
|
14
|
+
from her_sync.share.layout import ShareLayout
|
|
15
|
+
from her_sync.share.schemas import CASHFLOWS_HISTORY_COLUMNS, CASHFLOWS_UNASSIGNED_COLUMNS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CashflowCollectResult:
|
|
20
|
+
unassigned: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CashflowHistoryCollectResult:
|
|
25
|
+
history: int
|
|
26
|
+
cost_centers: int
|
|
27
|
+
skipped: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_unassigned_status(path) -> dict[int, dict[str, str]]:
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return {}
|
|
33
|
+
return {
|
|
34
|
+
int(row["id"]): {
|
|
35
|
+
"apply_status": str(row.get("apply_status") or "pending"),
|
|
36
|
+
"notes": str(row.get("notes") or ""),
|
|
37
|
+
}
|
|
38
|
+
for row in read_rows(path)
|
|
39
|
+
if row.get("id") is not None
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _unassigned_rows(cashflows, existing_status: dict[int, dict[str, str]]) -> list[dict]:
|
|
44
|
+
rows: list[dict] = []
|
|
45
|
+
for cf in cashflows:
|
|
46
|
+
prev = existing_status.get(cf.id, {})
|
|
47
|
+
rows.append(
|
|
48
|
+
{
|
|
49
|
+
"id": cf.id,
|
|
50
|
+
"valid_date": cf.valid_date,
|
|
51
|
+
"amount": cf.amount,
|
|
52
|
+
"reason": cf.reason,
|
|
53
|
+
"assigned_invoice_ids": cf.assigned_invoice_ids,
|
|
54
|
+
"splitted": cf.splitted,
|
|
55
|
+
"cash_payment": cf.cash_payment,
|
|
56
|
+
"apply_status": prev.get("apply_status", "pending"),
|
|
57
|
+
"notes": prev.get("notes", ""),
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return rows
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _history_rows(cashflows, invoice_service: InvoiceService) -> list[dict]:
|
|
64
|
+
creditor_cache: dict[int, str] = {}
|
|
65
|
+
rows: list[dict] = []
|
|
66
|
+
|
|
67
|
+
for cf in cashflows:
|
|
68
|
+
invoice_creditor = ""
|
|
69
|
+
if cf.assigned_invoice_ids:
|
|
70
|
+
inv_id = cf.assigned_invoice_ids[0]
|
|
71
|
+
if inv_id not in creditor_cache:
|
|
72
|
+
creditor_cache[inv_id] = invoice_service.fetch_creditor_name(inv_id)
|
|
73
|
+
invoice_creditor = creditor_cache[inv_id]
|
|
74
|
+
|
|
75
|
+
rows.append(
|
|
76
|
+
{
|
|
77
|
+
"id": cf.id,
|
|
78
|
+
"valid_date": cf.valid_date,
|
|
79
|
+
"amount": cf.amount,
|
|
80
|
+
"reason": cf.reason,
|
|
81
|
+
"cost_center_ident": cf.cost_center_ident,
|
|
82
|
+
"assigned_invoice_ids": cf.assigned_invoice_ids,
|
|
83
|
+
"invoice_creditor": invoice_creditor,
|
|
84
|
+
"splitted": cf.splitted,
|
|
85
|
+
"cash_payment": cf.cash_payment,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return rows
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def collect_cashflows(
|
|
93
|
+
settings: BdeSettings,
|
|
94
|
+
layout: ShareLayout,
|
|
95
|
+
) -> CashflowCollectResult:
|
|
96
|
+
"""Buchungen ohne Kostenstelle (schnell, type=no_cost_center)."""
|
|
97
|
+
layout.ensure()
|
|
98
|
+
existing_status = _load_unassigned_status(layout.cashflows_unassigned)
|
|
99
|
+
|
|
100
|
+
with BdeSession(
|
|
101
|
+
base_url=settings.bde_base_url,
|
|
102
|
+
basic_user=settings.bde_basic_auth_user,
|
|
103
|
+
basic_pass=settings.bde_basic_auth_pass,
|
|
104
|
+
login=settings.bde_login,
|
|
105
|
+
password=settings.bde_password,
|
|
106
|
+
cookie_file=settings.bde_cookie_file(),
|
|
107
|
+
) as session:
|
|
108
|
+
session.ensure_logged_in()
|
|
109
|
+
service = CashflowService(session, settings.bde_company_id)
|
|
110
|
+
unassigned = service.fetch_no_cost_center()
|
|
111
|
+
|
|
112
|
+
write_rows(
|
|
113
|
+
layout.cashflows_unassigned,
|
|
114
|
+
CASHFLOWS_UNASSIGNED_COLUMNS,
|
|
115
|
+
_unassigned_rows(unassigned, existing_status),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return CashflowCollectResult(unassigned=len(unassigned))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def collect_cashflow_history(
|
|
122
|
+
settings: BdeSettings,
|
|
123
|
+
layout: ShareLayout,
|
|
124
|
+
history_months: int = 6,
|
|
125
|
+
*,
|
|
126
|
+
skip_if_exists: bool = False,
|
|
127
|
+
) -> CashflowHistoryCollectResult:
|
|
128
|
+
"""Historische Buchungen für Kostenstellen-Muster (langsam: type=all).
|
|
129
|
+
|
|
130
|
+
``history_months`` filtert nur client-seitig nach dem Download.
|
|
131
|
+
"""
|
|
132
|
+
layout.ensure()
|
|
133
|
+
|
|
134
|
+
if skip_if_exists and layout.cashflows_history.exists():
|
|
135
|
+
return CashflowHistoryCollectResult(history=0, cost_centers=0, skipped=True)
|
|
136
|
+
|
|
137
|
+
with BdeSession(
|
|
138
|
+
base_url=settings.bde_base_url,
|
|
139
|
+
basic_user=settings.bde_basic_auth_user,
|
|
140
|
+
basic_pass=settings.bde_basic_auth_pass,
|
|
141
|
+
login=settings.bde_login,
|
|
142
|
+
password=settings.bde_password,
|
|
143
|
+
cookie_file=settings.bde_cookie_file(),
|
|
144
|
+
) as session:
|
|
145
|
+
session.ensure_logged_in()
|
|
146
|
+
cashflow_service = CashflowService(session, settings.bde_company_id)
|
|
147
|
+
invoice_service = InvoiceService(session, settings.bde_company_id)
|
|
148
|
+
|
|
149
|
+
raw = cashflow_service.fetch_history(months=history_months)
|
|
150
|
+
history = CashflowService.filter_history_patterns(raw)
|
|
151
|
+
cost_center_map = load_cost_center_map(session, settings.bde_company_id)
|
|
152
|
+
|
|
153
|
+
write_rows(
|
|
154
|
+
layout.cashflows_history,
|
|
155
|
+
CASHFLOWS_HISTORY_COLUMNS,
|
|
156
|
+
_history_rows(history, invoice_service),
|
|
157
|
+
)
|
|
158
|
+
layout.cost_centers.write_text(
|
|
159
|
+
json.dumps(cost_center_map, indent=2, ensure_ascii=False),
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return CashflowHistoryCollectResult(
|
|
164
|
+
history=len(history),
|
|
165
|
+
cost_centers=len(cost_center_map),
|
|
166
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Offene Rechnungen sammeln."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from her_sync.bde.invoices import InvoiceService
|
|
8
|
+
from her_sync.bde.session import BdeSession
|
|
9
|
+
from her_sync.config import BdeSettings
|
|
10
|
+
from her_sync.io.xlsx import write_rows
|
|
11
|
+
from her_sync.share.layout import ShareLayout
|
|
12
|
+
from her_sync.share.schemas import OPEN_INVOICES_COLUMNS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class InvoiceCollectResult:
|
|
17
|
+
open_creditor: int
|
|
18
|
+
gaps: int
|
|
19
|
+
exported: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def collect_open_invoices(
|
|
23
|
+
settings: BdeSettings,
|
|
24
|
+
layout: ShareLayout,
|
|
25
|
+
*,
|
|
26
|
+
gaps_only: bool = False,
|
|
27
|
+
) -> InvoiceCollectResult:
|
|
28
|
+
layout.ensure()
|
|
29
|
+
|
|
30
|
+
with BdeSession(
|
|
31
|
+
base_url=settings.bde_base_url,
|
|
32
|
+
basic_user=settings.bde_basic_auth_user,
|
|
33
|
+
basic_pass=settings.bde_basic_auth_pass,
|
|
34
|
+
login=settings.bde_login,
|
|
35
|
+
password=settings.bde_password,
|
|
36
|
+
cookie_file=settings.bde_cookie_file(),
|
|
37
|
+
) as session:
|
|
38
|
+
session.ensure_logged_in()
|
|
39
|
+
service = InvoiceService(session, settings.bde_company_id)
|
|
40
|
+
open_creditor = service.fetch_all_open_creditor()
|
|
41
|
+
gaps = [inv for inv in open_creditor if service._has_gap(inv)]
|
|
42
|
+
invoices = gaps if gaps_only else open_creditor
|
|
43
|
+
|
|
44
|
+
write_rows(
|
|
45
|
+
layout.open_invoices,
|
|
46
|
+
OPEN_INVOICES_COLUMNS,
|
|
47
|
+
[
|
|
48
|
+
{
|
|
49
|
+
"invoice_id": inv.id,
|
|
50
|
+
"invoice_number": inv.invoice_number,
|
|
51
|
+
"invoice_date": inv.invoice_date,
|
|
52
|
+
"due_date": inv.due_date,
|
|
53
|
+
"amount": inv.amount,
|
|
54
|
+
"creditor_name": inv.creditor_name,
|
|
55
|
+
"state": inv.state,
|
|
56
|
+
"has_bookings": inv.has_bookings,
|
|
57
|
+
"booking_ids": inv.booking_ids,
|
|
58
|
+
"has_receipts": inv.has_receipts,
|
|
59
|
+
"receipt_count": inv.receipt_count,
|
|
60
|
+
"gap_type": service.gap_type(inv),
|
|
61
|
+
}
|
|
62
|
+
for inv in invoices
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return InvoiceCollectResult(
|
|
67
|
+
open_creditor=len(open_creditor),
|
|
68
|
+
gaps=len(gaps),
|
|
69
|
+
exported=len(invoices),
|
|
70
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Belege aus konfiguriertem FS-Pfad ins Share-FS kopieren."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from her_sync.config import BdeSettings
|
|
9
|
+
from her_sync.share.layout import ShareLayout
|
|
10
|
+
|
|
11
|
+
RECEIPT_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".heic"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def collect_receipts(settings: BdeSettings, layout: ShareLayout) -> int:
|
|
15
|
+
layout.ensure()
|
|
16
|
+
source = settings.receipts_source_path
|
|
17
|
+
if not source.exists():
|
|
18
|
+
raise FileNotFoundError(f"RECEIPTS_SOURCE_PATH nicht gefunden: {source}")
|
|
19
|
+
|
|
20
|
+
copied = 0
|
|
21
|
+
for src in sorted(source.rglob("*")):
|
|
22
|
+
if not src.is_file() or src.suffix.lower() not in RECEIPT_EXTENSIONS:
|
|
23
|
+
continue
|
|
24
|
+
dest = layout.receipts / src.name
|
|
25
|
+
if dest.exists() and dest.stat().st_size == src.stat().st_size:
|
|
26
|
+
continue
|
|
27
|
+
shutil.copy2(src, dest)
|
|
28
|
+
copied += 1
|
|
29
|
+
|
|
30
|
+
return copied
|