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/__init__.py
ADDED
her_sync/__main__.py
ADDED
|
@@ -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
|
+
)
|
her_sync/bde/__init__.py
ADDED
|
@@ -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
|
her_sync/bde/invoices.py
ADDED
|
@@ -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 ""
|
her_sync/bde/session.py
ADDED
|
@@ -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()
|