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.
Files changed (44) hide show
  1. her_sync/__init__.py +3 -0
  2. her_sync/__main__.py +3 -0
  3. her_sync/apply/__init__.py +8 -0
  4. her_sync/apply/cost_centers.py +84 -0
  5. her_sync/apply/invoices.py +77 -0
  6. her_sync/bde/__init__.py +11 -0
  7. her_sync/bde/cashflows.py +150 -0
  8. her_sync/bde/cost_centers.py +22 -0
  9. her_sync/bde/invoices.py +199 -0
  10. her_sync/bde/session.py +124 -0
  11. her_sync/cli.py +407 -0
  12. her_sync/cli_helpers.py +10 -0
  13. her_sync/collectors/__init__.py +10 -0
  14. her_sync/collectors/cashflows.py +166 -0
  15. her_sync/collectors/invoices.py +70 -0
  16. her_sync/collectors/receipts.py +30 -0
  17. her_sync/config.py +153 -0
  18. her_sync/daemon.py +102 -0
  19. her_sync/io/paths.py +69 -0
  20. her_sync/io/xlsx.py +51 -0
  21. her_sync/migrate.py +252 -0
  22. her_sync/plugins/__init__.py +1 -0
  23. her_sync/plugins/bde.py +180 -0
  24. her_sync/plugins/caldav.py +124 -0
  25. her_sync/plugins/email.py +59 -0
  26. her_sync/profile.py +275 -0
  27. her_sync/secrets.py +92 -0
  28. her_sync/sync/__init__.py +3 -0
  29. her_sync/sync/caldav_client.py +199 -0
  30. her_sync/sync/calendar.py +287 -0
  31. her_sync/sync/calendar_filters.py +53 -0
  32. her_sync/sync/calendar_priority.py +59 -0
  33. her_sync/sync/email.py +498 -0
  34. her_sync/sync/email_filters.py +77 -0
  35. her_sync/sync/email_watcher.py +151 -0
  36. her_sync/sync/gloda_index.py +63 -0
  37. her_sync/sync/mbox_reader.py +163 -0
  38. her_sync/sync/msf_index.py +279 -0
  39. her_sync/sync/sync_lock.py +28 -0
  40. her_sync/tools/browser_record.py +76 -0
  41. her_sync-0.3.0.dist-info/METADATA +115 -0
  42. her_sync-0.3.0.dist-info/RECORD +44 -0
  43. her_sync-0.3.0.dist-info/WHEEL +4 -0
  44. 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()
@@ -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