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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """her-sync: Linux-native Sync-Tool für Agent-Share-Filesysteme."""
2
+
3
+ __version__ = "0.1.0"
her_sync/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from her_sync.cli import app
2
+
3
+ app()
@@ -0,0 +1,8 @@
1
+ from her_sync.apply.cost_centers import apply_cost_center_assignments
2
+ from her_sync.apply.invoices import apply_invoice_actions, plan_invoice_actions
3
+
4
+ __all__ = [
5
+ "apply_cost_center_assignments",
6
+ "apply_invoice_actions",
7
+ "plan_invoice_actions",
8
+ ]
@@ -0,0 +1,84 @@
1
+ """Kostenstellenzuordnung aus Agent-Ergebnissen anwenden."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from her_sync.bde.cashflows import CashflowService
6
+ from her_sync.bde.cost_centers import load_cost_center_map
7
+ from her_sync.bde.session import BdeSession
8
+ from her_sync.config import BdeSettings
9
+ from her_sync.io.xlsx import read_rows
10
+ from her_sync.share.layout import ShareLayout
11
+
12
+
13
+ def apply_cost_center_assignments(
14
+ settings: BdeSettings,
15
+ layout: ShareLayout,
16
+ *,
17
+ dry_run: bool = False,
18
+ ) -> list[tuple[int, str]]:
19
+ """Liest extract.xlsx und ordnet Kostenstellen zu.
20
+
21
+ Abbruch wenn Belegnr + Rechnungsdatum nicht eindeutig identifizierbar.
22
+ Gibt Liste der durchgeführten Zuordnungen zurück.
23
+ """
24
+ if not layout.extract_results.exists():
25
+ raise FileNotFoundError(f"Keine Agent-Ergebnisse: {layout.extract_results}")
26
+
27
+ rows = read_rows(layout.extract_results)
28
+ assignments: dict[int, str] = {}
29
+
30
+ for row in rows:
31
+ if row.get("status") != "complete":
32
+ continue
33
+ cf_id = row.get("cashflow_id")
34
+ cost_center = row.get("cost_center_ident")
35
+ invoice_number = row.get("invoice_number")
36
+ invoice_date = row.get("invoice_date")
37
+
38
+ if not cf_id or not cost_center:
39
+ continue
40
+
41
+ # Eindeutigkeit: Belegnr UND Rechnungsdatum müssen gesetzt sein
42
+ if not invoice_number or not invoice_date:
43
+ raise ValueError(
44
+ f"Zeile ohne eindeutige Identifikation (Belegnr+Datum): "
45
+ f"cashflow_id={cf_id}, file={row.get('relative_file_path')}"
46
+ )
47
+
48
+ cf_id = int(cf_id)
49
+ if cf_id in assignments and assignments[cf_id] != cost_center:
50
+ raise ValueError(
51
+ f"Widersprüchliche Kostenstelle für cashflow {cf_id}: "
52
+ f"{assignments[cf_id]} vs {cost_center}"
53
+ )
54
+ assignments[cf_id] = str(cost_center)
55
+
56
+ if dry_run:
57
+ return [(cf_id, cc) for cf_id, cc in assignments.items()]
58
+
59
+ cookie_file = settings.bde_cookie_file()
60
+ applied: list[tuple[int, str]] = []
61
+
62
+ with BdeSession(
63
+ base_url=settings.bde_base_url,
64
+ basic_user=settings.bde_basic_auth_user,
65
+ basic_pass=settings.bde_basic_auth_pass,
66
+ login=settings.bde_login,
67
+ password=settings.bde_password,
68
+ cookie_file=cookie_file,
69
+ ) as session:
70
+ session.ensure_logged_in()
71
+ service = CashflowService(session, settings.bde_company_id)
72
+
73
+ # cost_center_id Lookup — hier Platzhalter: Mapping aus Config oder BDE-Abfrage nötig
74
+ # TODO: cost_center ident -> id mapping aus BDE laden
75
+ cost_center_map = load_cost_center_map(session, settings.bde_company_id)
76
+
77
+ for cf_id, ident in assignments.items():
78
+ cc_id = cost_center_map.get(ident)
79
+ if cc_id is None:
80
+ raise ValueError(f"Unbekannte Kostenstelle: {ident}")
81
+ service.assign_cost_center([cf_id], cc_id)
82
+ applied.append((cf_id, ident))
83
+
84
+ return applied
@@ -0,0 +1,77 @@
1
+ """Rechnungszuordnung und -erstellung aus Agent-Ergebnissen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from her_sync.config import BdeSettings
8
+ from her_sync.io.xlsx import read_rows
9
+ from her_sync.share.layout import ShareLayout
10
+
11
+
12
+ @dataclass
13
+ class ApplyPlan:
14
+ """Geplante Aktionen vor Ausführung — für Review/Dry-Run."""
15
+
16
+ match_existing: list[dict]
17
+ create_new: list[dict]
18
+ skipped_incomplete: list[dict]
19
+ aborted_conflicts: list[dict]
20
+
21
+
22
+ def plan_invoice_actions(settings: BdeSettings, layout: ShareLayout) -> ApplyPlan:
23
+ """Plant Rechnungsaktionen ohne Ausführung.
24
+
25
+ Regeln:
26
+ - status=incomplete -> überspringen
27
+ - Belegnr + Rechnungsdatum müssen für Auto-Zuordnung vorhanden sein
28
+ - Abweichung bei bestehender Rechnung -> Konflikt, abbrechen
29
+ """
30
+ if not layout.extract_results.exists():
31
+ raise FileNotFoundError(f"Keine Agent-Ergebnisse: {layout.extract_results}")
32
+
33
+ rows = read_rows(layout.extract_results)
34
+ match_existing: list[dict] = []
35
+ create_new: list[dict] = []
36
+ skipped: list[dict] = []
37
+ conflicts: list[dict] = []
38
+
39
+ for row in rows:
40
+ if row.get("status") == "incomplete":
41
+ skipped.append(row)
42
+ continue
43
+
44
+ invoice_number = row.get("invoice_number")
45
+ invoice_date = row.get("invoice_date")
46
+ if not invoice_number or not invoice_date:
47
+ skipped.append(row)
48
+ continue
49
+
50
+ if row.get("cashflow_id"):
51
+ create_new.append(row)
52
+ else:
53
+ match_existing.append(row)
54
+
55
+ return ApplyPlan(
56
+ match_existing=match_existing,
57
+ create_new=create_new,
58
+ skipped_incomplete=skipped,
59
+ aborted_conflicts=conflicts,
60
+ )
61
+
62
+
63
+ def apply_invoice_actions(settings: BdeSettings, layout: ShareLayout, *, dry_run: bool = False) -> ApplyPlan:
64
+ """Führt geplante Rechnungsaktionen aus. Bei Abweichung sofort abbrechen."""
65
+ plan = plan_invoice_actions(settings, layout)
66
+ if dry_run:
67
+ return plan
68
+
69
+ # TODO: Implementierung nach Browser-Recording der Invoice-Forms
70
+ # - GET /companies/1/invoices?invoicable_type=Creditor&type=new
71
+ # - Upload businessattachments
72
+ # - POST update mit extrahierten Feldern
73
+ # - create_invoice aus übrigen cashflows
74
+ raise NotImplementedError(
75
+ "Invoice-Apply benötigt Browser-Recording der Formularfelder — "
76
+ "Browser-Recording (dev): playwright + manuelle Aufzeichnung nötig"
77
+ )
@@ -0,0 +1,11 @@
1
+ from her_sync.bde.cashflows import CashflowRow, CashflowService
2
+ from her_sync.bde.invoices import InvoiceRow, InvoiceService
3
+ from her_sync.bde.session import BdeSession
4
+
5
+ __all__ = [
6
+ "BdeSession",
7
+ "CashflowRow",
8
+ "CashflowService",
9
+ "InvoiceRow",
10
+ "InvoiceService",
11
+ ]
@@ -0,0 +1,150 @@
1
+ """BDE Cashflow-Operationen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime, timedelta
7
+
8
+ from bs4 import BeautifulSoup
9
+
10
+ from her_sync.bde.session import BdeSession
11
+
12
+ HISTORY_REASON = "Keiner"
13
+
14
+
15
+ @dataclass
16
+ class CashflowRow:
17
+ id: int
18
+ amount: float
19
+ valid_date: date
20
+ reason: str
21
+ cost_center_ident: str | None
22
+ assigned_invoice_ids: list[int]
23
+ splitted: bool
24
+ cash_payment: bool
25
+
26
+
27
+ class CashflowService:
28
+ def __init__(self, session: BdeSession, company_id: int):
29
+ self.session = session
30
+ self.company_id = company_id
31
+
32
+ def fetch_no_cost_center(self) -> list[CashflowRow]:
33
+ path = self.session.company_path(self.company_id, "cashflows?type=no_cost_center")
34
+ resp = self.session.client.get(path)
35
+ resp.raise_for_status()
36
+ return self._parse_cashflow_table(resp.text)
37
+
38
+ def fetch_history(self, months: int = 3, *, timeout: float = 300.0) -> list[CashflowRow]:
39
+ """Cashflows der letzten N Monate (type=all, client-seitig filtern).
40
+
41
+ BDE liefert alle Buchungen in einer HTML-Tabelle — kann bei großen
42
+ Datenmengen dauern (daher längeres Timeout).
43
+ """
44
+ cutoff = date.today() - timedelta(days=months * 31)
45
+ path = self.session.company_path(self.company_id, "cashflows?type=all")
46
+ resp = self.session.client.get(path, timeout=timeout)
47
+ resp.raise_for_status()
48
+ rows = self._parse_cashflow_table(resp.text)
49
+ return [r for r in rows if r.valid_date >= cutoff]
50
+
51
+ @staticmethod
52
+ def _is_keiner_reason(reason: str) -> bool:
53
+ return reason.strip() in ("", HISTORY_REASON)
54
+
55
+ @classmethod
56
+ def filter_history_patterns(cls, rows: list[CashflowRow]) -> list[CashflowRow]:
57
+ """Buchungen für Kostenstellen-Muster (nicht gesplittet, keine Barzahlung)."""
58
+ return [
59
+ r
60
+ for r in rows
61
+ if not r.splitted
62
+ and not r.cash_payment
63
+ and not cls._is_keiner_reason(r.reason)
64
+ and r.cost_center_ident
65
+ ]
66
+
67
+ def assign_cost_center(
68
+ self,
69
+ cashflow_ids: list[int],
70
+ cost_center_id: int,
71
+ list_type: str = "no_cost_center",
72
+ ) -> None:
73
+ path = self.session.company_path(self.company_id, "cashflows/cashflow-assign")
74
+ token = self.session.get_authenticity_token(
75
+ self.session.company_path(self.company_id, f"cashflows?type={list_type}")
76
+ )
77
+
78
+ data: dict[str, str] = {
79
+ "authenticity_token": token,
80
+ "type": list_type,
81
+ "cost_center_id": str(cost_center_id),
82
+ "assign": "Kostenstelle zuordnen",
83
+ }
84
+ for cf_id in cashflow_ids:
85
+ data[f"cashflows[{cf_id}][selected]"] = "1"
86
+
87
+ resp = self.session.client.post(path, data=data)
88
+ resp.raise_for_status()
89
+
90
+ @staticmethod
91
+ def _parse_cashflow_table(html: str) -> list[CashflowRow]:
92
+ soup = BeautifulSoup(html, "lxml")
93
+ rows: list[CashflowRow] = []
94
+
95
+ for tr in soup.select("table tbody tr"):
96
+ checkbox = tr.find("input", {"type": "checkbox"})
97
+ if not checkbox or not checkbox.get("name"):
98
+ continue
99
+
100
+ name = checkbox["name"]
101
+ # cashflows[24592][selected]
102
+ cf_id = int(name.split("[")[1].split("]")[0])
103
+ cells = tr.find_all("td")
104
+ if len(cells) < 5:
105
+ continue
106
+
107
+ assigned_links = tr.select("a[href*='/invoices/']")
108
+ invoice_ids = []
109
+ for link in assigned_links:
110
+ href = link.get("href", "")
111
+ if "/invoices/" in href:
112
+ try:
113
+ invoice_ids.append(int(href.split("/invoices/")[1].split("/")[0]))
114
+ except ValueError:
115
+ pass
116
+
117
+ amount_text = cells[2].get_text(strip=True).replace(",", ".")
118
+ try:
119
+ amount = float(amount_text)
120
+ except ValueError:
121
+ amount = 0.0
122
+
123
+ date_text = cells[3].get_text(strip=True)
124
+ try:
125
+ valid_date = datetime.strptime(date_text, "%Y-%m-%d").date()
126
+ except ValueError:
127
+ valid_date = date.today()
128
+
129
+ cost_center = cells[4].get_text(strip=True)
130
+ if cost_center in ("Keine", "-", ""):
131
+ cost_center = None
132
+
133
+ reason = cells[5].get_text(strip=True) if len(cells) > 5 else ""
134
+ splitted = "Gesplittet" in tr.get_text()
135
+ cash_payment = "BARZAHLUNG" in tr.get_text()
136
+
137
+ rows.append(
138
+ CashflowRow(
139
+ id=cf_id,
140
+ amount=amount,
141
+ valid_date=valid_date,
142
+ reason=reason,
143
+ cost_center_ident=cost_center,
144
+ assigned_invoice_ids=invoice_ids,
145
+ splitted=splitted,
146
+ cash_payment=cash_payment,
147
+ )
148
+ )
149
+
150
+ return rows
@@ -0,0 +1,22 @@
1
+ """BDE Kostenstellen-Lookup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bs4 import BeautifulSoup
6
+
7
+ from her_sync.bde.session import BdeSession
8
+
9
+
10
+ def load_cost_center_map(session: BdeSession, company_id: int) -> dict[str, int]:
11
+ """Kostenstellen-Ident → ID aus HTML-Select laden."""
12
+ path = session.company_path(company_id, "cashflows?type=no_cost_center")
13
+ resp = session.client.get(path)
14
+ resp.raise_for_status()
15
+ soup = BeautifulSoup(resp.text, "lxml")
16
+ mapping: dict[str, int] = {}
17
+ for opt in soup.select("select[name='cost_center_id'] option"):
18
+ value = opt.get("value")
19
+ ident = opt.get_text(strip=True)
20
+ if value and value.isdigit() and ident:
21
+ mapping[ident] = int(value)
22
+ return mapping
@@ -0,0 +1,199 @@
1
+ """BDE Rechnungs-Operationen."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date, datetime
7
+
8
+ from bs4 import BeautifulSoup
9
+
10
+ from her_sync.bde.session import BdeSession
11
+
12
+
13
+ @dataclass
14
+ class InvoiceRow:
15
+ id: int
16
+ invoice_number: str | None
17
+ invoice_date: date | None
18
+ due_date: date | None
19
+ amount: float | None
20
+ creditor_name: str
21
+ state: str
22
+ has_bookings: bool
23
+ booking_ids: list[int]
24
+ has_receipts: bool
25
+ receipt_count: int
26
+
27
+
28
+ class InvoiceService:
29
+ def __init__(self, session: BdeSession, company_id: int):
30
+ self.session = session
31
+ self.company_id = company_id
32
+
33
+ def fetch_creditor_invoices(self, state: str = "new") -> list[InvoiceRow]:
34
+ path = self.session.company_path(
35
+ self.company_id,
36
+ f"invoices?invoicable_type=Creditor&type={state}",
37
+ )
38
+ resp = self.session.client.get(path)
39
+ resp.raise_for_status()
40
+ return self._parse_invoice_list(resp.text, state)
41
+
42
+ def fetch_all_open_creditor(self) -> list[InvoiceRow]:
43
+ """Alle offenen Kreditorenrechnungen (type=new + type=booked)."""
44
+ seen: set[int] = set()
45
+ result: list[InvoiceRow] = []
46
+ for state in ("new", "booked"):
47
+ for inv in self.fetch_creditor_invoices(state):
48
+ if inv.id in seen:
49
+ continue
50
+ seen.add(inv.id)
51
+ result.append(inv)
52
+ return result
53
+
54
+ def fetch_creditor_name(self, invoice_id: int) -> str:
55
+ """Kreditor aus Rechnungsdetailseite (Text nach „Ziel:“)."""
56
+ path = self.session.company_path(self.company_id, f"invoices/{invoice_id}")
57
+ resp = self.session.client.get(path)
58
+ resp.raise_for_status()
59
+ return self._parse_creditor_from_detail(resp.text)
60
+
61
+ def fetch_open_gaps(self) -> list[InvoiceRow]:
62
+ """Kombiniert new + booked und filtert Lücken."""
63
+ seen: set[int] = set()
64
+ result: list[InvoiceRow] = []
65
+ for state in ("new", "booked"):
66
+ for inv in self.fetch_creditor_invoices(state):
67
+ if inv.id in seen:
68
+ continue
69
+ seen.add(inv.id)
70
+ if self._has_gap(inv):
71
+ result.append(inv)
72
+ return result
73
+
74
+ @staticmethod
75
+ def _has_gap(inv: InvoiceRow) -> bool:
76
+ has_bookings = inv.has_bookings
77
+ has_receipts = inv.has_receipts
78
+ if has_bookings and not has_receipts:
79
+ return True
80
+ if has_receipts and not has_bookings:
81
+ return True
82
+ if has_bookings and has_receipts and inv.receipt_count == 0:
83
+ return True
84
+ return False
85
+
86
+ @staticmethod
87
+ def gap_type(inv: InvoiceRow) -> str:
88
+ if inv.has_bookings and not inv.has_receipts:
89
+ return "missing_receipt"
90
+ if inv.has_receipts and not inv.has_bookings:
91
+ return "missing_booking"
92
+ if inv.has_bookings and inv.has_receipts:
93
+ return "partial_both"
94
+ return "unknown"
95
+
96
+ @staticmethod
97
+ def _parse_invoice_list(html: str, state: str) -> list[InvoiceRow]:
98
+ soup = BeautifulSoup(html, "lxml")
99
+ rows: list[InvoiceRow] = []
100
+
101
+ for tr in soup.select("table tbody tr"):
102
+ link = tr.find("a", href=lambda h: h and "/invoices/" in h and "/edit" not in h)
103
+ if not link:
104
+ continue
105
+
106
+ href = link["href"]
107
+ try:
108
+ inv_id = int(href.split("/invoices/")[1].split("/")[0].split("?")[0])
109
+ except (IndexError, ValueError):
110
+ continue
111
+
112
+ cells = tr.find_all("td")
113
+ if len(cells) < 4:
114
+ continue
115
+
116
+ invoice_number = cells[0].get_text(" ", strip=True) or None
117
+ creditor_name = cells[1].get_text(" ", strip=True)
118
+ amount_text = cells[2].get_text(strip=True).replace(",", ".")
119
+ date_text = cells[3].get_text(strip=True)
120
+ row_state = cells[4].get_text(strip=True) if len(cells) > 4 else state
121
+
122
+ try:
123
+ amount = float(amount_text)
124
+ except ValueError:
125
+ amount = _extract_amount(tr.get_text(" ", strip=True))
126
+
127
+ invoice_date = _parse_date_text(date_text) or _extract_date(tr.get_text(" ", strip=True))
128
+ text = tr.get_text(" ", strip=True)
129
+
130
+ rows.append(
131
+ InvoiceRow(
132
+ id=inv_id,
133
+ invoice_number=invoice_number,
134
+ invoice_date=invoice_date,
135
+ due_date=None,
136
+ amount=amount,
137
+ creditor_name=creditor_name,
138
+ state=row_state or state,
139
+ has_bookings="Buchung" in text or "cashflow" in text.lower(),
140
+ booking_ids=[],
141
+ has_receipts="Anhang" in text or "Beleg" in text or "attachment" in text.lower(),
142
+ receipt_count=text.lower().count("anhang") + text.lower().count("beleg"),
143
+ )
144
+ )
145
+
146
+ return rows
147
+
148
+ @staticmethod
149
+ def _parse_creditor_from_detail(html: str) -> str:
150
+ import re
151
+
152
+ soup = BeautifulSoup(html, "lxml")
153
+ for node in soup.find_all(string=re.compile(r"Ziel\s*:")):
154
+ text = str(node).strip()
155
+ if text.startswith("Ziel:"):
156
+ return text.split(":", 1)[1].strip()
157
+ parent = node.parent
158
+ if parent:
159
+ full = parent.get_text(" ", strip=True)
160
+ if "Ziel:" in full:
161
+ return full.split("Ziel:", 1)[1].strip()
162
+ match = re.search(r"Ziel:\s*(.+?)(?:\n|$)", soup.get_text("\n"))
163
+ return match.group(1).strip() if match else ""
164
+
165
+
166
+ def _parse_date_text(value: str) -> date | None:
167
+ value = value.strip()
168
+ for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
169
+ try:
170
+ return datetime.strptime(value, fmt).date()
171
+ except ValueError:
172
+ continue
173
+ return None
174
+
175
+
176
+ def _extract_date(text: str) -> date | None:
177
+ import re
178
+
179
+ for fmt in ("%Y-%m-%d", "%d.%m.%Y"):
180
+ for match in re.findall(r"\d{4}-\d{2}-\d{2}|\d{2}\.\d{2}\.\d{4}", text):
181
+ try:
182
+ return datetime.strptime(match, fmt).date()
183
+ except ValueError:
184
+ continue
185
+ return None
186
+
187
+
188
+ def _extract_amount(text: str) -> float | None:
189
+ import re
190
+
191
+ match = re.search(r"(-?\d+[.,]\d{2})", text)
192
+ if match:
193
+ return float(match.group(1).replace(",", "."))
194
+ return None
195
+
196
+
197
+ def _extract_creditor(text: str) -> str:
198
+ # Heuristik — wird beim Browser-Recording verfeinert
199
+ return ""
@@ -0,0 +1,124 @@
1
+ """BDE Session-Handling: Basic-Auth + POST-Login + Cookie."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from bs4 import BeautifulSoup
10
+
11
+
12
+ class BdeSession:
13
+ """HTTP-Client mit BDE-Authentifizierung."""
14
+
15
+ def __init__(
16
+ self,
17
+ base_url: str,
18
+ basic_user: str = "",
19
+ basic_pass: str = "",
20
+ login: str = "",
21
+ password: str = "",
22
+ cookie_file: Path | None = None,
23
+ ):
24
+ self.base_url = base_url.rstrip("/")
25
+ self.login = login
26
+ self.password = password
27
+ self.cookie_file = cookie_file
28
+
29
+ auth = (basic_user, basic_pass) if basic_user else None
30
+ self.client = httpx.Client(
31
+ base_url=self.base_url,
32
+ auth=auth,
33
+ follow_redirects=True,
34
+ timeout=60.0,
35
+ headers={"User-Agent": "her-sync/0.2"},
36
+ )
37
+
38
+ if cookie_file and cookie_file.exists():
39
+ self._load_cookies()
40
+
41
+ def _clear_cookies(self) -> None:
42
+ self.client.cookies.clear()
43
+ if self.cookie_file and self.cookie_file.exists():
44
+ self.cookie_file.unlink()
45
+
46
+ def _load_cookies(self) -> None:
47
+ import json
48
+
49
+ data = json.loads(self.cookie_file.read_text())
50
+ for name, value in data.items():
51
+ self.client.cookies.set(name, value)
52
+
53
+ def _save_cookies(self) -> None:
54
+ if not self.cookie_file:
55
+ return
56
+ import json
57
+
58
+ self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
59
+ cookies = {c.name: c.value for c in self.client.cookies.jar}
60
+ self.cookie_file.write_text(json.dumps(cookies, indent=2))
61
+
62
+ def login_post(self) -> bool:
63
+ """POST /session mit login/password."""
64
+ resp = self.client.get("/session/new")
65
+ token = self._extract_authenticity_token(resp.text)
66
+
67
+ data = {
68
+ "login": self.login,
69
+ "password": self.password,
70
+ "commit": "Login",
71
+ }
72
+ if token:
73
+ data["authenticity_token"] = token
74
+
75
+ resp = self.client.post("/session", data=data)
76
+ self._save_cookies()
77
+ return self._is_logged_in(resp)
78
+
79
+ def ensure_logged_in(self) -> None:
80
+ resp = self.client.get("/")
81
+ if not self._is_logged_in(resp):
82
+ self._clear_cookies()
83
+ if not self.login_post():
84
+ raise RuntimeError("BDE-Login fehlgeschlagen — Credentials prüfen")
85
+
86
+ @staticmethod
87
+ def _is_logged_in(resp: httpx.Response) -> bool:
88
+ if resp.status_code == 401:
89
+ return False
90
+ url = str(resp.url)
91
+ if "/session/new" in url:
92
+ return False
93
+ if "fehlgeschlagen" in resp.text.lower():
94
+ return False
95
+ return True
96
+
97
+ @staticmethod
98
+ def _extract_authenticity_token(html: str) -> str | None:
99
+ soup = BeautifulSoup(html, "lxml")
100
+ tag = soup.find("input", {"name": "authenticity_token"})
101
+ if tag and tag.get("value"):
102
+ return tag["value"]
103
+ match = re.search(r'name="authenticity_token"\s+value="([^"]+)"', html)
104
+ return match.group(1) if match else None
105
+
106
+ def get_authenticity_token(self, path: str) -> str:
107
+ resp = self.client.get(path)
108
+ resp.raise_for_status()
109
+ token = self._extract_authenticity_token(resp.text)
110
+ if not token:
111
+ raise RuntimeError(f"Kein authenticity_token auf {path}")
112
+ return token
113
+
114
+ def company_path(self, company_id: int, subpath: str) -> str:
115
+ return f"/companies/{company_id}/{subpath.lstrip('/')}"
116
+
117
+ def close(self) -> None:
118
+ self.client.close()
119
+
120
+ def __enter__(self) -> BdeSession:
121
+ return self
122
+
123
+ def __exit__(self, *args) -> None:
124
+ self.close()