euer-datev 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- euer_datev/__init__.py +10 -0
- euer_datev/cli.py +419 -0
- euer_datev/config.py +262 -0
- euer_datev/constants.py +151 -0
- euer_datev/crypto.py +211 -0
- euer_datev/exporter.py +221 -0
- euer_datev/license.py +205 -0
- euer_datev/mapping.py +342 -0
- euer_datev/models.py +118 -0
- euer_datev/packager.py +180 -0
- euer_datev/plugin.py +85 -0
- euer_datev/reader.py +193 -0
- euer_datev/validator.py +261 -0
- euer_datev-0.1.0.dist-info/METADATA +206 -0
- euer_datev-0.1.0.dist-info/RECORD +19 -0
- euer_datev-0.1.0.dist-info/WHEEL +5 -0
- euer_datev-0.1.0.dist-info/entry_points.txt +5 -0
- euer_datev-0.1.0.dist-info/licenses/LICENSE +20 -0
- euer_datev-0.1.0.dist-info/top_level.txt +1 -0
euer_datev/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""euer-datev: Offizieller DATEV-Export (EXTF 700) & Kanzlei-Schnittstelle für euer.
|
|
2
|
+
|
|
3
|
+
Ermöglicht den dateibasierten 1-Klick-Export von Buchungsdaten und Belegen
|
|
4
|
+
im offiziellen DATEV-Format zur nahtlosen Übernahme in DATEV Kanzlei-Rechnungswesen.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
VERSION = "0.1.0"
|
|
8
|
+
__version__ = VERSION
|
|
9
|
+
|
|
10
|
+
__all__ = ["VERSION", "__version__"]
|
euer_datev/cli.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Standalone CLI Interface für euer-datev."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import VERSION
|
|
8
|
+
from .config import generate_default_skr_template, load_datev_config
|
|
9
|
+
from .constants import DEFAULT_BERATER_NUMMER, DEFAULT_MANDANTEN_NUMMER
|
|
10
|
+
from .exporter import write_extf_csv_file
|
|
11
|
+
from .license import save_license_token, verify_license
|
|
12
|
+
from .mapping import assign_unique_beleglinks, map_transaction_to_datev
|
|
13
|
+
from .packager import package_datev_export
|
|
14
|
+
from .reader import get_db_connection, read_transactions_from_db
|
|
15
|
+
from .validator import validate_bookings
|
|
16
|
+
|
|
17
|
+
if sys.platform == "win32":
|
|
18
|
+
try:
|
|
19
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
20
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
22
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_print(*args, **kwargs) -> None:
|
|
28
|
+
"""Sicheres print für Plattformen mit eingeschränktem Konsolen-Encoding (z. B. Windows cp1252)."""
|
|
29
|
+
file = kwargs.get("file") or sys.stdout
|
|
30
|
+
sep = kwargs.get("sep", " ")
|
|
31
|
+
end = kwargs.get("end", "\n")
|
|
32
|
+
text = sep.join(str(a) for a in args) + end
|
|
33
|
+
try:
|
|
34
|
+
file.write(text)
|
|
35
|
+
file.flush()
|
|
36
|
+
except (UnicodeEncodeError, AttributeError):
|
|
37
|
+
encoding = getattr(file, "encoding", None) or "ascii"
|
|
38
|
+
sanitized = text.encode(encoding, errors="replace").decode(encoding)
|
|
39
|
+
file.write(sanitized)
|
|
40
|
+
file.flush()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
print = _safe_print
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_default_db_path() -> Path:
|
|
47
|
+
"""Sucht nach der standardmaessigen euer.db."""
|
|
48
|
+
local_db = Path.cwd() / ".euer" / "euer.db"
|
|
49
|
+
if local_db.is_file():
|
|
50
|
+
return local_db
|
|
51
|
+
|
|
52
|
+
local_root_db = Path.cwd() / "euer.db"
|
|
53
|
+
if local_root_db.is_file():
|
|
54
|
+
return local_root_db
|
|
55
|
+
|
|
56
|
+
home_db = Path.home() / ".config" / "euer" / "euer.db"
|
|
57
|
+
return home_db
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
61
|
+
"""Fuehrt den DATEV-Export durch."""
|
|
62
|
+
db_path = Path(args.db) if args.db else find_default_db_path()
|
|
63
|
+
if not db_path.is_file():
|
|
64
|
+
print(f"Fehler: Datenbank '{db_path}' nicht gefunden.", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
config = load_datev_config(
|
|
68
|
+
config_path=args.config,
|
|
69
|
+
override_year=args.year,
|
|
70
|
+
override_skr=args.skr,
|
|
71
|
+
override_berater=args.berater,
|
|
72
|
+
override_mandant=args.mandant,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
conn = get_db_connection(db_path)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
print(f"Fehler beim Verbinden mit der Datenbank: {exc}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
raw_txs = read_transactions_from_db(
|
|
83
|
+
conn=conn,
|
|
84
|
+
year=args.year,
|
|
85
|
+
month=args.month,
|
|
86
|
+
quarter=args.quarter,
|
|
87
|
+
from_date=args.from_date,
|
|
88
|
+
to_date=args.to_date,
|
|
89
|
+
)
|
|
90
|
+
finally:
|
|
91
|
+
conn.close()
|
|
92
|
+
|
|
93
|
+
if not raw_txs:
|
|
94
|
+
print("Hinweis: Keine Buchungen im angegebenen Zeitraum gefunden.", file=sys.stderr)
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
# Tatsächliche Buchungsjahre aus den Transaktionen ermitteln
|
|
98
|
+
years_in_tx = sorted({int(tx.date[:4]) for tx in raw_txs if tx.date and len(tx.date) >= 4})
|
|
99
|
+
if not years_in_tx:
|
|
100
|
+
print("Fehler: Buchungsdaten enthalten kein gültiges Buchungsdatum.", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
# Dynamische Erkennung des Wirtschaftsjahres falls kein --year angegeben wurde
|
|
104
|
+
if not args.year and len(years_in_tx) == 1:
|
|
105
|
+
detected_year = years_in_tx[0]
|
|
106
|
+
config.wirtschaftsjahr_beginn = f"{detected_year}0101"
|
|
107
|
+
|
|
108
|
+
# Lizenzprüfung gegen jedes tatsächlich in den Buchungen vorkommende Steuerjahr
|
|
109
|
+
# Verhindert jede Umgehung durch Weglassen von --year (z. B. via --from-date / --to-date)
|
|
110
|
+
for yr in years_in_tx:
|
|
111
|
+
lic = verify_license(target_year=yr)
|
|
112
|
+
if not lic.is_valid:
|
|
113
|
+
print(f"❌ Lizenzfehler: {lic.message}", file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
# Evaluierungs-Modus: Beschränkung auf maximal 5 Buchungssätze zur Probe
|
|
117
|
+
lic_status = verify_license(target_year=years_in_tx[0])
|
|
118
|
+
if lic_status.is_evaluation:
|
|
119
|
+
max_eval_bookings = 5
|
|
120
|
+
if len(raw_txs) > max_eval_bookings:
|
|
121
|
+
print(
|
|
122
|
+
f"\n❌ Evaluierungs-Modus: Im Evaluierungsmodus können maximal {max_eval_bookings} "
|
|
123
|
+
f"Buchungssätze zur Probe exportiert werden (gefunden: {len(raw_txs)}).\n"
|
|
124
|
+
f" Für den vollständigen Export des Steuerjahres {years_in_tx[0]} aktiviere bitte eine Lizenz:\n\n"
|
|
125
|
+
f" euer datev license activate <DEIN-LIZENZSCHLÜSSEL>\n\n"
|
|
126
|
+
f" Lizenzen erwerben unter: https://euer-buchhaltung.de/datev\n",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
return 1
|
|
130
|
+
else:
|
|
131
|
+
print(
|
|
132
|
+
f"⚠️ PROBE-EXPORT (Evaluierungs-Modus: {len(raw_txs)}/{max_eval_bookings} Buchungssätze).\n"
|
|
133
|
+
f" Für den vollständigen Jahresabschluss bitte Lizenz aktivieren: https://euer-buchhaltung.de/datev\n",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Transformation in DATEV-Buchungssätze
|
|
138
|
+
bookings = [map_transaction_to_datev(tx, config) for tx in raw_txs]
|
|
139
|
+
assign_unique_beleglinks(bookings)
|
|
140
|
+
|
|
141
|
+
# Validierung
|
|
142
|
+
val_res = validate_bookings(bookings, config)
|
|
143
|
+
if not val_res.is_valid and not args.force:
|
|
144
|
+
print("\nFehler bei der Vorab-Prüfung des DATEV-Exports:", file=sys.stderr)
|
|
145
|
+
for issue in val_res.issues:
|
|
146
|
+
if issue.severity == "ERROR":
|
|
147
|
+
print(f" ❌ [{issue.code}] {issue.message}", file=sys.stderr)
|
|
148
|
+
print(
|
|
149
|
+
"\nExport abgebrochen. Behebe die Fehler oder nutze '--force' zum Erzwingen.",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
# Zeitraum ermitteln
|
|
155
|
+
all_dates = [tx.date[:10].replace("-", "") for tx in raw_txs if tx.date]
|
|
156
|
+
min_date = min(all_dates) if all_dates else f"{args.year or 2026}0101"
|
|
157
|
+
max_date = max(all_dates) if all_dates else f"{args.year or 2026}1231"
|
|
158
|
+
|
|
159
|
+
# Ausgabepfad festlegen
|
|
160
|
+
export_fmt = getattr(args, "format", "zip")
|
|
161
|
+
year_str = str(args.year) if args.year else config.wirtschaftsjahr_beginn[:4]
|
|
162
|
+
suffix = f"_{year_str}"
|
|
163
|
+
if args.quarter:
|
|
164
|
+
suffix += f"_Q{args.quarter}"
|
|
165
|
+
elif args.month:
|
|
166
|
+
suffix += f"_M{args.month:02d}"
|
|
167
|
+
|
|
168
|
+
if args.output:
|
|
169
|
+
out_path = Path(args.output)
|
|
170
|
+
if out_path.is_dir() or (not out_path.suffix and not out_path.exists()):
|
|
171
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
filename = f"DATEV_EXTF{suffix}.{export_fmt}"
|
|
173
|
+
target_path = out_path / filename
|
|
174
|
+
else:
|
|
175
|
+
target_path = out_path
|
|
176
|
+
else:
|
|
177
|
+
out_dir = config.export_dir or (Path.cwd() / "exports")
|
|
178
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
target_path = out_dir / f"DATEV_EXTF{suffix}.{export_fmt}"
|
|
180
|
+
|
|
181
|
+
# Export ausfuehren
|
|
182
|
+
if export_fmt == "csv":
|
|
183
|
+
write_extf_csv_file(
|
|
184
|
+
target_path=target_path,
|
|
185
|
+
bookings=bookings,
|
|
186
|
+
config=config,
|
|
187
|
+
date_from=min_date,
|
|
188
|
+
date_to=max_date,
|
|
189
|
+
)
|
|
190
|
+
print(f"✅ DATEV EXTF-700 CSV erfolgreich exportiert: {target_path}")
|
|
191
|
+
else:
|
|
192
|
+
res = package_datev_export(
|
|
193
|
+
target_zip_path=target_path,
|
|
194
|
+
bookings=bookings,
|
|
195
|
+
config=config,
|
|
196
|
+
date_from=min_date,
|
|
197
|
+
date_to=max_date,
|
|
198
|
+
validation_result=val_res,
|
|
199
|
+
)
|
|
200
|
+
if not val_res.is_valid and not args.force:
|
|
201
|
+
print(
|
|
202
|
+
f"Exportpaket {target_path} enthält Fehler beim Verpacken der Belege. "
|
|
203
|
+
"Siehe PRUEFPROTOKOLL.txt im Archiv.",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
return 1
|
|
207
|
+
print(f"✅ DATEV-Exportpaket erfolgreich erstellt: {res.zip_path}")
|
|
208
|
+
print(f" • {res.total_bookings} Buchungssätze ({res.total_volume_eur:,.2f} EUR)")
|
|
209
|
+
print(f" • {res.total_receipts_packaged} Belege im Archiv abgelegt (Beleglink-Referenz)")
|
|
210
|
+
if res.missing_receipts_count > 0:
|
|
211
|
+
print(
|
|
212
|
+
f" ⚠️ {res.missing_receipts_count} Belegdateien nicht gefunden (Details in PRÜFPROTOKOLL.txt)"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if val_res.warning_count > 0:
|
|
216
|
+
print(f" ℹ️ {val_res.warning_count} Warnungen bei der Validierung aufgetreten.")
|
|
217
|
+
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
222
|
+
"""Prueft die Buchungsdaten auf DATEV-Konformitaet ohne Datei zu schreiben."""
|
|
223
|
+
db_path = Path(args.db) if args.db else find_default_db_path()
|
|
224
|
+
if not db_path.is_file():
|
|
225
|
+
print(f"Fehler: Datenbank '{db_path}' nicht gefunden.", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
config = load_datev_config(
|
|
229
|
+
config_path=args.config,
|
|
230
|
+
override_year=args.year,
|
|
231
|
+
override_skr=args.skr,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
conn = get_db_connection(db_path)
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
print(f"Fehler beim Verbinden mit der Datenbank: {exc}", file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
raw_txs = read_transactions_from_db(conn=conn, year=args.year)
|
|
242
|
+
finally:
|
|
243
|
+
conn.close()
|
|
244
|
+
|
|
245
|
+
if not raw_txs:
|
|
246
|
+
print("Keine Buchungen gefunden.", file=sys.stderr)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
if not args.year:
|
|
250
|
+
years_in_tx = {tx.date[:4] for tx in raw_txs if tx.date and len(tx.date) >= 4}
|
|
251
|
+
if len(years_in_tx) == 1:
|
|
252
|
+
detected_year = list(years_in_tx)[0]
|
|
253
|
+
config.wirtschaftsjahr_beginn = f"{detected_year}0101"
|
|
254
|
+
|
|
255
|
+
bookings = [map_transaction_to_datev(tx, config) for tx in raw_txs]
|
|
256
|
+
assign_unique_beleglinks(bookings)
|
|
257
|
+
val_res = validate_bookings(bookings, config)
|
|
258
|
+
|
|
259
|
+
print(f"\nDATEV-Validierungsbericht für SKR {config.skr} ({len(bookings)} Buchungen):")
|
|
260
|
+
print(f" Fehler (ERROR): {val_res.error_count}")
|
|
261
|
+
print(f" Warnungen (WARN): {val_res.warning_count}")
|
|
262
|
+
print(f" Hinweise (INFO): {val_res.info_count}\n")
|
|
263
|
+
|
|
264
|
+
for issue in val_res.issues:
|
|
265
|
+
icon = "❌" if issue.severity == "ERROR" else ("⚠️" if issue.severity == "WARNING" else "ℹ️")
|
|
266
|
+
print(f" {icon} [{issue.severity}] {issue.message}")
|
|
267
|
+
|
|
268
|
+
if not val_res.is_valid:
|
|
269
|
+
print("\n❌ Validierung fehlgeschlagen: Behebe die Fehler vor dem Export.", file=sys.stderr)
|
|
270
|
+
return 1
|
|
271
|
+
|
|
272
|
+
print("\n✅ Validierung erfolgreich: Buchungsstapel ist bereit für DATEV.")
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def cmd_init_skr(args: argparse.Namespace) -> int:
|
|
277
|
+
"""Gibt empfohlene Konfigurations-Templates für SKR03 oder SKR04 aus."""
|
|
278
|
+
template = generate_default_skr_template(skr=args.skr)
|
|
279
|
+
if args.write:
|
|
280
|
+
target = Path(args.write)
|
|
281
|
+
if target.is_file():
|
|
282
|
+
content = target.read_text(encoding="utf-8")
|
|
283
|
+
content += "\n\n" + template
|
|
284
|
+
target.write_text(content, encoding="utf-8")
|
|
285
|
+
print(f"✅ SKR-{args.skr}-Vorlage an {target} angehängt.")
|
|
286
|
+
else:
|
|
287
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
target.write_text(template, encoding="utf-8")
|
|
289
|
+
print(f"✅ SKR-{args.skr}-Vorlage in neue Datei {target} geschrieben.")
|
|
290
|
+
else:
|
|
291
|
+
print(template)
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_license(args: argparse.Namespace) -> int:
|
|
296
|
+
"""Verwaltet den Lizenzschlüssel."""
|
|
297
|
+
if args.action == "status":
|
|
298
|
+
status = verify_license()
|
|
299
|
+
print("\neuer-datev Lizenzstatus:")
|
|
300
|
+
print(f" Gültig: {'Ja' if status.is_valid else 'Nein'}")
|
|
301
|
+
print(f" Lizenztyp: {status.tier.capitalize()}")
|
|
302
|
+
print(f" Lizenznehmer: {status.licensee}")
|
|
303
|
+
if status.allowed_years:
|
|
304
|
+
print(f" Steuerjahr: {', '.join(str(y) for y in status.allowed_years)}")
|
|
305
|
+
elif status.tier in ("kanzlei", "pro"):
|
|
306
|
+
print(" Steuerjahr: Alle (Kanzlei-Lizenz)")
|
|
307
|
+
if status.active_tokens_count > 0:
|
|
308
|
+
print(f" Lizenzen: {status.active_tokens_count} aktiv hinterlegt")
|
|
309
|
+
print(f" Details: {status.message}\n")
|
|
310
|
+
return 0 if status.is_valid else 1
|
|
311
|
+
elif args.action == "activate":
|
|
312
|
+
if not args.key:
|
|
313
|
+
print(
|
|
314
|
+
"Fehler: Lizenzschlüssel erforderlich (z. B. euer datev license activate <SCHLÜSSEL>).",
|
|
315
|
+
file=sys.stderr,
|
|
316
|
+
)
|
|
317
|
+
return 1
|
|
318
|
+
clean_key = args.key.strip()
|
|
319
|
+
try:
|
|
320
|
+
path, status = save_license_token(clean_key)
|
|
321
|
+
print(f"✅ Lizenz erfolgreich aktiviert und hinzugefügt in: {path}")
|
|
322
|
+
print(f" Lizenznehmer: {status.licensee}")
|
|
323
|
+
if status.allowed_years:
|
|
324
|
+
print(
|
|
325
|
+
f" Freigeschaltete Steuerjahre: {', '.join(str(y) for y in status.allowed_years)}"
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
print(" Freigeschaltete Steuerjahre: Alle (Kanzlei-Lizenz)")
|
|
329
|
+
return 0
|
|
330
|
+
except ValueError as exc:
|
|
331
|
+
print(f"❌ Ungültiger Lizenzschlüssel: {exc}", file=sys.stderr)
|
|
332
|
+
return 1
|
|
333
|
+
return 0
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
337
|
+
"""Baut den Standalone-Kommandozeilen-Parser."""
|
|
338
|
+
parser = argparse.ArgumentParser(
|
|
339
|
+
prog="euer-datev",
|
|
340
|
+
description="euer-datev: Offizieller DATEV-Export (EXTF 700) für euer Buchhaltung",
|
|
341
|
+
)
|
|
342
|
+
parser.add_argument("--version", action="version", version=f"euer-datev {VERSION}")
|
|
343
|
+
|
|
344
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
345
|
+
|
|
346
|
+
# Subcommand: export
|
|
347
|
+
p_export = subparsers.add_parser("export", help="Exportiert Buchungsdaten im DATEV-Format")
|
|
348
|
+
p_export.add_argument("--db", help="Pfad zur euer.db")
|
|
349
|
+
p_export.add_argument("--config", help="Pfad zur config.toml")
|
|
350
|
+
p_export.add_argument("--year", type=int, help="Steuerjahr filtern (z. B. 2026)")
|
|
351
|
+
p_export.add_argument("--month", type=int, choices=range(1, 13), help="Monat filtern (1-12)")
|
|
352
|
+
p_export.add_argument("--quarter", type=int, choices=[1, 2, 3, 4], help="Quartal filtern (1-4)")
|
|
353
|
+
p_export.add_argument("--from-date", help="Von Datum (YYYY-MM-DD)")
|
|
354
|
+
p_export.add_argument("--to-date", help="Bis Datum (YYYY-MM-DD)")
|
|
355
|
+
p_export.add_argument(
|
|
356
|
+
"--skr", choices=["03", "04"], default=None, help="Kontenrahmen SKR 03 oder SKR 04"
|
|
357
|
+
)
|
|
358
|
+
p_export.add_argument(
|
|
359
|
+
"--berater", default=None, help=f"Beraternummer (default: {DEFAULT_BERATER_NUMMER})"
|
|
360
|
+
)
|
|
361
|
+
p_export.add_argument(
|
|
362
|
+
"--mandant", default=None, help=f"Mandantennummer (default: {DEFAULT_MANDANTEN_NUMMER})"
|
|
363
|
+
)
|
|
364
|
+
p_export.add_argument(
|
|
365
|
+
"--format",
|
|
366
|
+
choices=["zip", "csv"],
|
|
367
|
+
default="zip",
|
|
368
|
+
help="Ausgabeformat: zip (inkl. Belegen) oder csv",
|
|
369
|
+
)
|
|
370
|
+
p_export.add_argument("-o", "--output", help="Ausgabedatei oder Zielverzeichnis")
|
|
371
|
+
p_export.add_argument(
|
|
372
|
+
"--force", action="store_true", help="Export trotz Validierungsfehlern erzwingen"
|
|
373
|
+
)
|
|
374
|
+
p_export.set_defaults(func=cmd_export)
|
|
375
|
+
|
|
376
|
+
# Subcommand: validate
|
|
377
|
+
p_validate = subparsers.add_parser(
|
|
378
|
+
"validate", help="Prueft Daten auf DATEV-Konformitaet ohne Export"
|
|
379
|
+
)
|
|
380
|
+
p_validate.add_argument("--db", help="Pfad zur euer.db")
|
|
381
|
+
p_validate.add_argument("--config", help="Pfad zur config.toml")
|
|
382
|
+
p_validate.add_argument("--year", type=int, help="Steuerjahr filtern (z. B. 2026)")
|
|
383
|
+
p_validate.add_argument(
|
|
384
|
+
"--skr", choices=["03", "04"], default=None, help="Kontenrahmen SKR 03 oder SKR 04"
|
|
385
|
+
)
|
|
386
|
+
p_validate.set_defaults(func=cmd_validate)
|
|
387
|
+
|
|
388
|
+
# Subcommand: init-skr
|
|
389
|
+
p_init = subparsers.add_parser("init-skr", help="Erzeugt TOML-Vorlage für SKR 03 oder SKR 04")
|
|
390
|
+
p_init.add_argument(
|
|
391
|
+
"--skr", choices=["03", "04"], default="03", help="Kontenrahmen wählen (default: 03)"
|
|
392
|
+
)
|
|
393
|
+
p_init.add_argument("--write", help="In Zieldatei schreiben oder anhängen")
|
|
394
|
+
p_init.set_defaults(func=cmd_init_skr)
|
|
395
|
+
|
|
396
|
+
# Subcommand: license
|
|
397
|
+
p_lic = subparsers.add_parser("license", help="Lizenzschlüssel verwalten")
|
|
398
|
+
p_lic.add_argument(
|
|
399
|
+
"action", choices=["status", "activate"], help="Aktion (status oder activate)"
|
|
400
|
+
)
|
|
401
|
+
p_lic.add_argument("key", nargs="?", help="Lizenzschlüssel bei activate")
|
|
402
|
+
p_lic.set_defaults(func=cmd_license)
|
|
403
|
+
|
|
404
|
+
return parser
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main() -> None:
|
|
408
|
+
"""Haupt-Einstiegspunkt für die CLI."""
|
|
409
|
+
parser = build_parser()
|
|
410
|
+
args = parser.parse_args()
|
|
411
|
+
if hasattr(args, "func"):
|
|
412
|
+
sys.exit(args.func(args))
|
|
413
|
+
else:
|
|
414
|
+
parser.print_help()
|
|
415
|
+
sys.exit(1)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|
euer_datev/config.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Konfigurations-Lader und Standard-Templates für euer-datev."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .constants import (
|
|
8
|
+
DEFAULT_BERATER_NUMMER,
|
|
9
|
+
DEFAULT_MANDANTEN_NUMMER,
|
|
10
|
+
EXTF_ACCOUNT_LENGTH_DEFAULT,
|
|
11
|
+
SKR03_EXPENSE_FALLBACKS,
|
|
12
|
+
SKR03_FINANCIAL_ACCOUNTS,
|
|
13
|
+
SKR04_EXPENSE_FALLBACKS,
|
|
14
|
+
SKR04_FINANCIAL_ACCOUNTS,
|
|
15
|
+
)
|
|
16
|
+
from .models import DatevConfig
|
|
17
|
+
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
import tomllib
|
|
20
|
+
else:
|
|
21
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "euer"
|
|
25
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_config_path(explicit_path: Path | str | None = None) -> Path:
|
|
29
|
+
"""Ermittelt den Pfad zur config.toml (explizit > Projektverzeichnis > User-Config)."""
|
|
30
|
+
if explicit_path:
|
|
31
|
+
return Path(explicit_path)
|
|
32
|
+
|
|
33
|
+
# Projekt-lokale Config
|
|
34
|
+
local_config = Path.cwd() / ".euer" / "config.toml"
|
|
35
|
+
if local_config.is_file():
|
|
36
|
+
return local_config
|
|
37
|
+
|
|
38
|
+
return DEFAULT_CONFIG_PATH
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_raw_toml(path: Path) -> dict[str, Any]:
|
|
42
|
+
"""Laedt eine TOML-Datei sicher via tomllib."""
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
return {}
|
|
45
|
+
try:
|
|
46
|
+
with open(path, "rb") as f:
|
|
47
|
+
return tomllib.load(f)
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
raise ValueError(f"Fehler beim Parsen der Konfigurationsdatei {path}: {exc}") from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_datev_config(
|
|
53
|
+
config_path: Path | str | None = None,
|
|
54
|
+
override_year: int | None = None,
|
|
55
|
+
override_skr: str | None = None,
|
|
56
|
+
override_berater: str | None = None,
|
|
57
|
+
override_mandant: str | None = None,
|
|
58
|
+
) -> DatevConfig:
|
|
59
|
+
"""Laedt die DatevConfig aus der Datei und wendet ggf. CLI-Overrides an."""
|
|
60
|
+
resolved_path = find_config_path(config_path)
|
|
61
|
+
data = load_raw_toml(resolved_path)
|
|
62
|
+
|
|
63
|
+
datev_sec = data.get("datev", {})
|
|
64
|
+
if not isinstance(datev_sec, dict):
|
|
65
|
+
datev_sec = {}
|
|
66
|
+
|
|
67
|
+
# Grunddaten
|
|
68
|
+
skr = override_skr or str(datev_sec.get("skr", "03")).strip()
|
|
69
|
+
if skr not in ("03", "04"):
|
|
70
|
+
skr = "03"
|
|
71
|
+
|
|
72
|
+
berater = (
|
|
73
|
+
override_berater or str(datev_sec.get("berater_nummer", DEFAULT_BERATER_NUMMER)).strip()
|
|
74
|
+
)
|
|
75
|
+
mandant = (
|
|
76
|
+
override_mandant or str(datev_sec.get("mandanten_nummer", DEFAULT_MANDANTEN_NUMMER)).strip()
|
|
77
|
+
)
|
|
78
|
+
mandanten_name = str(
|
|
79
|
+
datev_sec.get("mandanten_name", data.get("user", {}).get("name", ""))
|
|
80
|
+
).strip()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
acc_len = int(datev_sec.get("sachkonten_laenge", EXTF_ACCOUNT_LENGTH_DEFAULT))
|
|
84
|
+
except (ValueError, TypeError):
|
|
85
|
+
acc_len = EXTF_ACCOUNT_LENGTH_DEFAULT
|
|
86
|
+
|
|
87
|
+
# Wirtschaftsjahr Beginn
|
|
88
|
+
year = override_year or 2026
|
|
89
|
+
wj_beginn = f"{year}0101"
|
|
90
|
+
|
|
91
|
+
# Steuermodus (standard vs small_business)
|
|
92
|
+
tax_mode = (
|
|
93
|
+
str(datev_sec.get("tax_mode", data.get("tax", {}).get("mode", "standard"))).strip().lower()
|
|
94
|
+
)
|
|
95
|
+
if tax_mode not in ("standard", "small_business"):
|
|
96
|
+
tax_mode = "standard"
|
|
97
|
+
|
|
98
|
+
# Receipts Root
|
|
99
|
+
receipts_root: Path | None = None
|
|
100
|
+
receipts_cfg = data.get("receipts", {}).get("root")
|
|
101
|
+
if receipts_cfg:
|
|
102
|
+
receipts_root = Path(receipts_cfg).expanduser()
|
|
103
|
+
if not receipts_root.is_absolute() and resolved_path.is_file():
|
|
104
|
+
receipts_root = resolved_path.parent / receipts_root
|
|
105
|
+
|
|
106
|
+
# Export Dir (unterstützt 'exports.directory' aus euer sowie 'exports.dir')
|
|
107
|
+
export_dir: Path | None = None
|
|
108
|
+
export_cfg = data.get("exports", {}).get("directory") or data.get("exports", {}).get("dir")
|
|
109
|
+
if export_cfg:
|
|
110
|
+
export_dir = Path(export_cfg).expanduser()
|
|
111
|
+
|
|
112
|
+
# Kontenrahmen aus Spec 010 ([[ledger_accounts]]) sowie [datev.ledger_accounts]
|
|
113
|
+
ledger_mappings: dict[str, str] = {}
|
|
114
|
+
raw_datev_ledger = datev_sec.get("ledger_accounts", {})
|
|
115
|
+
if isinstance(raw_datev_ledger, dict):
|
|
116
|
+
for k, v in raw_datev_ledger.items():
|
|
117
|
+
if k and v:
|
|
118
|
+
ledger_mappings[str(k).strip().lower()] = str(v).strip()
|
|
119
|
+
|
|
120
|
+
raw_ledger = data.get("ledger_accounts", [])
|
|
121
|
+
if isinstance(raw_ledger, list):
|
|
122
|
+
for entry in raw_ledger:
|
|
123
|
+
if isinstance(entry, dict):
|
|
124
|
+
acc_num = entry.get("account_number")
|
|
125
|
+
if not acc_num:
|
|
126
|
+
continue
|
|
127
|
+
acc_str = str(acc_num).strip()
|
|
128
|
+
for field_name in ("key", "name"):
|
|
129
|
+
val = str(entry.get(field_name, "")).strip().lower()
|
|
130
|
+
if val and val not in ledger_mappings:
|
|
131
|
+
ledger_mappings[val] = acc_str
|
|
132
|
+
|
|
133
|
+
# Finanzkonten Mapping:
|
|
134
|
+
# 1. Prio: [datev.accounts]
|
|
135
|
+
# 2. Prio: [accounts.mapping] (SPEC-07 Konvention)
|
|
136
|
+
# 3. Prio: [accounts] (flache Tabelle)
|
|
137
|
+
fin_mappings: dict[str, str] = {}
|
|
138
|
+
|
|
139
|
+
raw_datev_fin = datev_sec.get("accounts", {})
|
|
140
|
+
if isinstance(raw_datev_fin, dict):
|
|
141
|
+
for acc_name, acc_num in raw_datev_fin.items():
|
|
142
|
+
if acc_name and acc_num:
|
|
143
|
+
fin_mappings[str(acc_name).strip().lower()] = str(acc_num).strip()
|
|
144
|
+
|
|
145
|
+
raw_accounts_mapping = data.get("accounts", {}).get("mapping", {})
|
|
146
|
+
if isinstance(raw_accounts_mapping, dict):
|
|
147
|
+
for acc_name, acc_num in raw_accounts_mapping.items():
|
|
148
|
+
key = str(acc_name).strip().lower()
|
|
149
|
+
acc_val = str(acc_num).strip()
|
|
150
|
+
if not key or not acc_val:
|
|
151
|
+
continue
|
|
152
|
+
# Sachkonten vs Finanzkonten unterscheiden:
|
|
153
|
+
# SKR03: Finanzkonten 1000-1999; SKR04: 1000-2999
|
|
154
|
+
is_fin = (skr == "03" and acc_val.startswith("1")) or (
|
|
155
|
+
skr == "04" and acc_val[:1] in ("1", "2")
|
|
156
|
+
)
|
|
157
|
+
if is_fin:
|
|
158
|
+
if key not in fin_mappings:
|
|
159
|
+
fin_mappings[key] = acc_val
|
|
160
|
+
else:
|
|
161
|
+
if key not in ledger_mappings:
|
|
162
|
+
ledger_mappings[key] = acc_val
|
|
163
|
+
|
|
164
|
+
raw_accounts = data.get("accounts", {})
|
|
165
|
+
if isinstance(raw_accounts, dict):
|
|
166
|
+
for acc_name, acc_num in raw_accounts.items():
|
|
167
|
+
if acc_name not in ("private", "mapping") and isinstance(acc_num, (str, int)):
|
|
168
|
+
key = str(acc_name).strip().lower()
|
|
169
|
+
acc_val = str(acc_num).strip()
|
|
170
|
+
if key and acc_val and key not in fin_mappings:
|
|
171
|
+
fin_mappings[key] = acc_val
|
|
172
|
+
|
|
173
|
+
# Fallback-Finanzkonten ergänzen falls leer
|
|
174
|
+
defaults_fin = SKR03_FINANCIAL_ACCOUNTS if skr == "03" else SKR04_FINANCIAL_ACCOUNTS
|
|
175
|
+
for default_name, default_num in defaults_fin.items():
|
|
176
|
+
if default_name not in fin_mappings:
|
|
177
|
+
fin_mappings[default_name] = default_num
|
|
178
|
+
|
|
179
|
+
return DatevConfig(
|
|
180
|
+
berater_nummer=berater,
|
|
181
|
+
mandanten_nummer=mandant,
|
|
182
|
+
skr=skr,
|
|
183
|
+
wirtschaftsjahr_beginn=wj_beginn,
|
|
184
|
+
sachkonten_laenge=acc_len,
|
|
185
|
+
mandanten_name=mandanten_name,
|
|
186
|
+
tax_mode=tax_mode,
|
|
187
|
+
financial_account_mappings=fin_mappings,
|
|
188
|
+
ledger_account_mappings=ledger_mappings,
|
|
189
|
+
receipts_root=receipts_root,
|
|
190
|
+
export_dir=export_dir,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def generate_default_skr_template(skr: str = "03") -> str:
|
|
195
|
+
"""Erzeugt einen empfohlenen [datev]- und [[ledger_accounts]]-Block für config.toml."""
|
|
196
|
+
fallbacks = SKR03_EXPENSE_FALLBACKS if skr == "03" else SKR04_EXPENSE_FALLBACKS
|
|
197
|
+
fin_accs = SKR03_FINANCIAL_ACCOUNTS if skr == "03" else SKR04_FINANCIAL_ACCOUNTS
|
|
198
|
+
|
|
199
|
+
lines = [
|
|
200
|
+
"# --- DATEV-Konfiguration (euer-datev) ---",
|
|
201
|
+
"[datev]",
|
|
202
|
+
f'berater_nummer = "{DEFAULT_BERATER_NUMMER}"',
|
|
203
|
+
f'mandanten_nummer = "{DEFAULT_MANDANTEN_NUMMER}"',
|
|
204
|
+
f'skr = "{skr}"',
|
|
205
|
+
"sachkonten_laenge = 4",
|
|
206
|
+
'mandanten_name = "Mein Unternehmen"',
|
|
207
|
+
"",
|
|
208
|
+
"[datev.accounts]",
|
|
209
|
+
]
|
|
210
|
+
for name, num in fin_accs.items():
|
|
211
|
+
lines.append(f'"{name}" = "{num}"')
|
|
212
|
+
|
|
213
|
+
lines.extend(
|
|
214
|
+
[
|
|
215
|
+
"",
|
|
216
|
+
f"# --- Buchungskonten (SKR {skr} Vorlage für Spec 010) ---",
|
|
217
|
+
]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
sample_ledger = [
|
|
221
|
+
(
|
|
222
|
+
"saas",
|
|
223
|
+
"Software & SaaS-Lizenzen",
|
|
224
|
+
"Laufende EDV-Kosten",
|
|
225
|
+
fallbacks.get("laufende edv-kosten", "4930"),
|
|
226
|
+
),
|
|
227
|
+
("hosting", "Server & Hosting", "Laufende EDV-Kosten", "4940" if skr == "03" else "6820"),
|
|
228
|
+
(
|
|
229
|
+
"gwg",
|
|
230
|
+
"Geringwertige Wirtschaftsgüter",
|
|
231
|
+
"Arbeitsmittel",
|
|
232
|
+
fallbacks.get("arbeitsmittel", "0480"),
|
|
233
|
+
),
|
|
234
|
+
(
|
|
235
|
+
"telefon",
|
|
236
|
+
"Telefon & Internet",
|
|
237
|
+
"Telefon / Internet",
|
|
238
|
+
fallbacks.get("telefon / internet", "4920"),
|
|
239
|
+
),
|
|
240
|
+
("reise", "Reisekosten Unternehmer", "Reisekosten", fallbacks.get("reisekosten", "4670")),
|
|
241
|
+
(
|
|
242
|
+
"bewirtung",
|
|
243
|
+
"Geschäftliche Bewirtung 70%",
|
|
244
|
+
"Bewirtungsaufwendungen",
|
|
245
|
+
fallbacks.get("bewirtungskosten", "4650"),
|
|
246
|
+
),
|
|
247
|
+
("werbung", "Marketing & Anzeigen", "Werbekosten", fallbacks.get("werbekosten", "4600")),
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
for key, name, cat, num in sample_ledger:
|
|
251
|
+
lines.extend(
|
|
252
|
+
[
|
|
253
|
+
"[[ledger_accounts]]",
|
|
254
|
+
f'key = "{key}"',
|
|
255
|
+
f'name = "{name}"',
|
|
256
|
+
f'category = "{cat}"',
|
|
257
|
+
f'account_number = "{num}"',
|
|
258
|
+
"",
|
|
259
|
+
]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return "\n".join(lines)
|