extf 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.
extf/_writer.py ADDED
@@ -0,0 +1,565 @@
1
+ """Writers of files in the DATEV format (``EXTF``, format version 700).
2
+
3
+ A **Buchungsstapel** (category 21, Formatversion 13), **Kontenbeschriftungen** (category 20)
4
+ and **Debitoren/Kreditoren** (category 16): Windows-1252, fields separated by ``;``, text in
5
+ double quotes, amounts with a decimal comma, lines ending in CRLF — what :mod:`extf` reads.
6
+
7
+ **Validation:** before anything is written, every field is checked against the format —
8
+ lengths, characters, account numbers, the period (:func:`validate`). One bad field refuses
9
+ the whole file, every one named (:class:`~extf.ExtfError`). Nothing is cut or guessed
10
+ silently, except what the columns ask for: a label or name longer than its column is cut
11
+ there (the long label keeps all of it), and a VAT id that is no EU one is left out.
12
+
13
+ **Deterministic:** the same input gives the same bytes. ``Erzeugt am`` is
14
+ :attr:`ExportHeader.created`, never the clock.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import calendar
20
+ import re
21
+ import unicodedata
22
+ from collections.abc import Collection, Sequence
23
+ from dataclasses import dataclass
24
+ from datetime import date, datetime
25
+ from decimal import Decimal
26
+
27
+ from extf._errors import ExtfError, LineError
28
+ from extf._format import (
29
+ ACCOUNT_LABELS,
30
+ ACCOUNT_LENGTHS,
31
+ BOOKINGS,
32
+ CATEGORY_NAMES,
33
+ DATEV_ENCODING,
34
+ PERSONAL_ACCOUNTS,
35
+ fiscal_year_end,
36
+ is_reversal_key,
37
+ is_utf8,
38
+ round_money,
39
+ )
40
+
41
+ #: The version of the DATEV format and of each category's layout written here.
42
+ FORMAT_VERSION = 700
43
+ CATEGORY_VERSIONS = {BOOKINGS: 13, ACCOUNT_LABELS: 3, PERSONAL_ACCOUNTS: 5}
44
+ #: Ranges of the numbers in the header.
45
+ ADVISOR_NUMBERS = (1001, 9999999)
46
+ CLIENT_NUMBERS = (1, 99999)
47
+
48
+ #: The columns of a Buchungsstapel, Formatversion 13, in the order of the format (125).
49
+ BOOKING_COLUMNS: tuple[str, ...] = (
50
+ "Umsatz (ohne Soll/Haben-Kz)",
51
+ "Soll/Haben-Kennzeichen",
52
+ "WKZ Umsatz",
53
+ "Kurs",
54
+ "Basis-Umsatz",
55
+ "WKZ Basis-Umsatz",
56
+ "Konto",
57
+ "Gegenkonto (ohne BU-Schlüssel)",
58
+ "BU-Schlüssel",
59
+ "Belegdatum",
60
+ "Belegfeld 1",
61
+ "Belegfeld 2",
62
+ "Skonto",
63
+ "Buchungstext",
64
+ "Postensperre",
65
+ "Diverse Adressnummer",
66
+ "Geschäftspartnerbank",
67
+ "Sachverhalt",
68
+ "Zinssperre",
69
+ "Beleglink",
70
+ *(name for number in range(1, 9) for name in (f"Beleginfo - Art {number}", f"Beleginfo - Inhalt {number}")),
71
+ "KOST1 - Kostenstelle",
72
+ "KOST2 - Kostenstelle",
73
+ "Kost-Menge",
74
+ "EU-Land u. UStID (Bestimmung)",
75
+ "EU-Steuersatz (Bestimmung)",
76
+ "Abw. Versteuerungsart",
77
+ "Sachverhalt L+L",
78
+ "Funktionsergänzung L+L",
79
+ "BU 49 Hauptfunktionstyp",
80
+ "BU 49 Hauptfunktionsnummer",
81
+ "BU 49 Funktionsergänzung",
82
+ *(
83
+ name
84
+ for number in range(1, 21)
85
+ for name in (f"Zusatzinformation - Art {number}", f"Zusatzinformation - Inhalt {number}")
86
+ ),
87
+ "Stück",
88
+ "Gewicht",
89
+ "Zahlweise",
90
+ "Forderungsart",
91
+ "Veranlagungsjahr",
92
+ "Zugeordnete Fälligkeit",
93
+ "Skontotyp",
94
+ "Auftragsnummer",
95
+ "Buchungstyp",
96
+ "USt-Schlüssel (Anzahlungen)",
97
+ "EU-Land (Anzahlungen)",
98
+ "Sachverhalt L+L (Anzahlungen)",
99
+ "EU-Steuersatz (Anzahlungen)",
100
+ "Erlöskonto (Anzahlungen)",
101
+ "Herkunft-Kz",
102
+ "Buchungs GUID",
103
+ "KOST-Datum",
104
+ "SEPA-Mandatsreferenz",
105
+ "Skontosperre",
106
+ "Gesellschaftername",
107
+ "Beteiligtennummer",
108
+ "Identifikationsnummer",
109
+ "Zeichnernummer",
110
+ "Postensperre bis",
111
+ "Bezeichnung SoBil-Sachverhalt",
112
+ "Kennzeichen SoBil-Buchung",
113
+ "Festschreibung",
114
+ "Leistungsdatum",
115
+ "Datum Zuord. Steuerperiode",
116
+ "Fälligkeit",
117
+ "Generalumkehr (GU)",
118
+ "Steuersatz",
119
+ "Land",
120
+ "Abrechnungsreferenz",
121
+ "BVV-Position",
122
+ "EU-Land u. UStID (Ursprung)",
123
+ "EU-Steuersatz (Ursprung)",
124
+ "Abw. Skontokonto",
125
+ )
126
+ _COLUMN = {name: position for position, name in enumerate(BOOKING_COLUMNS)}
127
+ LABEL_COLUMNS = ("Konto", "Kontenbeschriftung", "Sprach-ID", "Kontenbeschriftung lang")
128
+ PERSONAL_COLUMNS = (
129
+ "Konto",
130
+ "Name (Adressatentyp Unternehmen)",
131
+ "Unternehmensgegenstand",
132
+ "Name (Adressatentyp natürl. Person)",
133
+ "Vorname (Adressatentyp natürl. Person)",
134
+ "Name (Adressatentyp keine Angabe)",
135
+ "Adressatentyp",
136
+ "Kurzbezeichnung",
137
+ "EU-Land",
138
+ "EU-UStID",
139
+ )
140
+ #: Adressatentyp of a personal account: a company.
141
+ ADDRESSEE_COMPANY = "2"
142
+
143
+ #: Field lengths of the format.
144
+ _AMOUNT_LIMIT = Decimal(10) ** 10
145
+ _AMOUNT_DIGITS = 10
146
+ _NUMBER_LENGTH = 36
147
+ _TEXT_LENGTH = 60
148
+ _INFO_KIND_LENGTH = 20
149
+ _INFO_LENGTH = 210
150
+ _LABEL_LENGTH = 40
151
+ _LONG_LABEL_LENGTH = 300
152
+ _NAME_LENGTH = 50
153
+ _SHORT_NAME_LENGTH = 15
154
+ _TITLE_LENGTH = 30
155
+ _EXPORTED_BY_LENGTH = 25
156
+ _MAX_INFOS = 8
157
+ #: The characters Belegfeld 1 takes.
158
+ _NUMBER_CHARS = re.compile(r"[^A-Za-z0-9$&%*+\-/]")
159
+ _NUMBER_OK = re.compile(r"[A-Za-z0-9$&%*+\-/]*")
160
+ _TAX_KEY = re.compile(r"[0-9]{1,4}")
161
+ _DIGITS = re.compile(r"[0-9]+")
162
+ _ORIGIN = re.compile(r"[A-Z0-9]{2}")
163
+ _CHART = re.compile(r"[0-9]{2}")
164
+ #: An EU VAT id: two letters and the number.
165
+ _EU_VAT_ID = re.compile(r"([A-Z]{2})([0-9A-Z+*]{2,13})")
166
+ _EU_COUNTRIES = frozenset(
167
+ "AT BE BG CY CZ DE DK EE EL ES FI FR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK XI".split()
168
+ )
169
+ #: Line 1 is the header and line 2 the column names: record ``i`` (from 0) is on line ``i + 3``.
170
+ _FIRST_RECORD_LINE = 3
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class ExportHeader:
175
+ """What line 1 of a written file states."""
176
+
177
+ #: Beraternummer and Mandantennummer.
178
+ advisor_number: int
179
+ client_number: int
180
+ #: The first day of the fiscal year (WJ-Beginn).
181
+ fiscal_year_start: date
182
+ #: The length of a general ledger account number (Sachkontenlänge); a personal account
183
+ #: has one digit more.
184
+ account_length: int
185
+ #: Herkunft: two upper-case letters or digits.
186
+ origin: str
187
+ #: Exportiert von.
188
+ exported_by: str
189
+ #: The chart of accounts (SKR, two digits).
190
+ chart: str
191
+ #: ``Erzeugt am``; the caller states it, so that the same input gives the same bytes.
192
+ created: datetime
193
+ #: The period of a Buchungsstapel (Datum vom / Datum bis); not written for the other categories.
194
+ date_from: date | None = None
195
+ date_to: date | None = None
196
+ #: Festschreibung of a Buchungsstapel.
197
+ locked: bool = False
198
+
199
+
200
+ @dataclass(frozen=True, slots=True)
201
+ class Record:
202
+ """One record of a Buchungsstapel, as written."""
203
+
204
+ amount: Decimal
205
+ #: Konto and Gegenkonto as written (``S``: Konto debited).
206
+ account: str
207
+ contra_account: str
208
+ tax_key: str | None
209
+ booking_date: date
210
+ document_number: str | None
211
+ text: str
212
+ #: Generalumkehr: the record cancels an entry; the sides are its own (the reversed
213
+ #: entry's, swapped).
214
+ reversal: bool = False
215
+ #: Beleginfo: (Art, Inhalt) pairs, at most eight.
216
+ infos: tuple[tuple[str, str], ...] = ()
217
+
218
+ @property
219
+ def debit(self) -> str:
220
+ """The account the record debits (``S``: Konto)."""
221
+ return self.account
222
+
223
+ @property
224
+ def credit(self) -> str:
225
+ return self.contra_account
226
+
227
+
228
+ @dataclass(frozen=True, slots=True)
229
+ class Label:
230
+ number: str
231
+ label: str
232
+
233
+
234
+ @dataclass(frozen=True, slots=True)
235
+ class Personal:
236
+ number: str
237
+ name: str
238
+ vat_id: str | None = None
239
+
240
+
241
+ # --- text and numbers --------------------------------------------------------------------
242
+
243
+
244
+ def _encodable(text: str) -> bool:
245
+ try:
246
+ text.encode(DATEV_ENCODING)
247
+ except UnicodeEncodeError:
248
+ return False
249
+ return True
250
+
251
+
252
+ def clean_text(value: str | None) -> str:
253
+ """``value`` on one line, in the characters of Windows-1252: what it lacks is written
254
+ without its accents, or as ``?``."""
255
+ text = " ".join((value or "").split())
256
+ return "".join(
257
+ char if _encodable(char) else "".join(part for part in unicodedata.normalize("NFKD", char) if _encodable(part)) or "?"
258
+ for char in text
259
+ )
260
+
261
+
262
+ def document_number(value: str | None) -> str | None:
263
+ """Belegfeld 1: ``value`` with umlauts spelled out and every character the field does
264
+ not take replaced by ``-`` (``RE 2026.001`` → ``RE-2026-001``)."""
265
+ if value is None or not value.strip():
266
+ return None
267
+ text = value.strip()
268
+ for umlaut, ascii_ in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("Ä", "AE"), ("Ö", "OE"), ("Ü", "UE"), ("ß", "ss")):
269
+ text = text.replace(umlaut, ascii_)
270
+ return _NUMBER_CHARS.sub("-", text)
271
+
272
+
273
+ def amount_de(value: Decimal) -> str:
274
+ """An amount as the format writes it: two decimals, decimal comma, no grouping."""
275
+ return f"{round_money(value):.2f}".replace(".", ",")
276
+
277
+
278
+ # --- validation -------------------------------------------------------------------------
279
+
280
+
281
+ def _check_text(errors: list[LineError], where: tuple[str, int], what: str, value: str | None, limit: int) -> None:
282
+ if value is None:
283
+ return
284
+ if len(value) > limit:
285
+ errors.append(LineError(*where, f"{what} has {len(value)} characters, at most {limit}"))
286
+ if not _encodable(value):
287
+ errors.append(LineError(*where, f"{what} holds characters outside Windows-1252"))
288
+ if "\r" in value or "\n" in value:
289
+ errors.append(LineError(*where, f"{what} holds a line break"))
290
+ elif any(ord(char) < 32 or ord(char) == 127 for char in value):
291
+ errors.append(LineError(*where, f"{what} holds a control character"))
292
+
293
+
294
+ def _is_account(number: str, lengths: Collection[int]) -> bool:
295
+ return _DIGITS.fullmatch(number) is not None and len(number) in lengths and int(number) != 0
296
+
297
+
298
+ def _check_amount(errors: list[LineError], where: tuple[str, int], amount: Decimal) -> None:
299
+ if not isinstance(amount, Decimal) or not amount.is_finite():
300
+ errors.append(LineError(*where, f"Umsatz {amount!r} is no finite Decimal"))
301
+ elif amount <= 0:
302
+ errors.append(LineError(*where, "Umsatz must be above zero"))
303
+ elif amount >= _AMOUNT_LIMIT or round_money(amount) != amount:
304
+ errors.append(LineError(*where, f"Umsatz {amount} has more than {_AMOUNT_DIGITS} digits before or 2 after the comma"))
305
+
306
+
307
+ def _check_record(errors: list[LineError], where: tuple[str, int], record: Record, header: ExportHeader, allowed_keys: Collection[str] | None) -> None:
308
+ length = header.account_length
309
+ _check_amount(errors, where, record.amount)
310
+ for what, number in (("Konto", record.account), ("Gegenkonto", record.contra_account)):
311
+ if not _is_account(number, (length, length + 1)):
312
+ errors.append(LineError(*where, f"{what} {number!r} is neither a general ledger account of {length} digits nor a personal account of {length + 1}"))
313
+ if record.account == record.contra_account:
314
+ errors.append(LineError(*where, f"Konto and Gegenkonto are both {record.account}"))
315
+ if record.tax_key is not None:
316
+ if _TAX_KEY.fullmatch(record.tax_key) and is_reversal_key(record.tax_key):
317
+ errors.append(LineError(*where, f"BU-Schlüssel {record.tax_key!r} is the old notation of a Generalumkehr: use reversal=True"))
318
+ elif not _TAX_KEY.fullmatch(record.tax_key) or (allowed_keys is not None and record.tax_key not in allowed_keys):
319
+ errors.append(LineError(*where, f"BU-Schlüssel {record.tax_key!r} is no allowed tax key"))
320
+ if header.date_from is not None and header.date_to is not None and not header.date_from <= record.booking_date <= header.date_to:
321
+ errors.append(LineError(*where, f"Belegdatum {record.booking_date.isoformat()} lies outside the period"))
322
+ if record.document_number is not None:
323
+ if len(record.document_number) > _NUMBER_LENGTH:
324
+ errors.append(LineError(*where, f"Belegfeld 1 has {len(record.document_number)} characters, at most {_NUMBER_LENGTH}"))
325
+ if not _NUMBER_OK.fullmatch(record.document_number):
326
+ errors.append(LineError(*where, f"Belegfeld 1 {record.document_number!r} holds characters the field does not take"))
327
+ if not record.text.strip():
328
+ errors.append(LineError(*where, "Buchungstext is empty"))
329
+ _check_text(errors, where, "Buchungstext", record.text, _TEXT_LENGTH)
330
+ if len(record.infos) > _MAX_INFOS:
331
+ errors.append(LineError(*where, f"more than {_MAX_INFOS} Beleginfo fields"))
332
+ for kind, content in record.infos:
333
+ _check_text(errors, where, "Beleginfo - Art", kind, _INFO_KIND_LENGTH)
334
+ _check_text(errors, where, "Beleginfo - Inhalt", content, _INFO_LENGTH)
335
+
336
+
337
+ def _check_header(errors: list[LineError], header: ExportHeader) -> None:
338
+ where = ("header", 1)
339
+ low, high = ADVISOR_NUMBERS
340
+ if not low <= header.advisor_number <= high:
341
+ errors.append(LineError(*where, f"Beraternummer {header.advisor_number} lies outside {low}–{high}"))
342
+ low, high = CLIENT_NUMBERS
343
+ if not low <= header.client_number <= high:
344
+ errors.append(LineError(*where, f"Mandantennummer {header.client_number} lies outside {low}–{high}"))
345
+ low, high = ACCOUNT_LENGTHS
346
+ if not low <= header.account_length <= high:
347
+ errors.append(LineError(*where, f"Sachkontenlänge {header.account_length} lies outside {low}–{high}"))
348
+ if not _ORIGIN.fullmatch(header.origin):
349
+ errors.append(LineError(*where, f"Herkunft {header.origin!r} is not two upper-case letters or digits"))
350
+ _check_text(errors, where, "Exportiert von", header.exported_by, _EXPORTED_BY_LENGTH)
351
+ if not _CHART.fullmatch(header.chart):
352
+ errors.append(LineError(*where, f"the chart {header.chart!r} is not two digits"))
353
+
354
+
355
+ def _check_period(errors: list[LineError], header: ExportHeader) -> None:
356
+ where = ("header", 1)
357
+ date_from, date_to = header.date_from, header.date_to
358
+ if date_from is None or date_to is None:
359
+ errors.append(LineError(*where, "a Buchungsstapel needs its period (date_from, date_to)"))
360
+ return
361
+ try:
362
+ past_year_end = date_to > fiscal_year_end(header.fiscal_year_start)
363
+ except ValueError as exc:
364
+ errors.append(LineError(*where, str(exc)))
365
+ past_year_end = False
366
+ if date_to < date_from or past_year_end:
367
+ errors.append(LineError(*where, "the period does not lie in one fiscal year"))
368
+ if date_from < header.fiscal_year_start:
369
+ errors.append(LineError(*where, "the period begins before the fiscal year"))
370
+ if date_to >= date_from:
371
+ _check_text(errors, where, "Bezeichnung", _title(header, BOOKINGS), _TITLE_LENGTH)
372
+
373
+
374
+ def _check_label_file(errors: list[LineError], header: ExportHeader, labels: Sequence[Label]) -> None:
375
+ """The one check on the file as written: its column line is ASCII, so nothing but its
376
+ text keeps it from being valid UTF-8, which a reader refuses."""
377
+ lines = _label_lines(header, labels)
378
+ try:
379
+ data = _encode(lines)
380
+ except UnicodeEncodeError:
381
+ return # Named by _check_text; there is no written file to judge.
382
+ if is_utf8(data):
383
+ line = next(number for number, text in enumerate(lines, start=1) if not text.isascii())
384
+ reason = "the written file is valid UTF-8: its text would be read back as UTF-8, which the reader refuses"
385
+ errors.append(LineError(CATEGORY_NAMES[ACCOUNT_LABELS], line, reason))
386
+
387
+
388
+ def validate(
389
+ header: ExportHeader,
390
+ records: Sequence[Record] | None = None,
391
+ labels: Sequence[Label] | None = None,
392
+ personal: Sequence[Personal] | None = None,
393
+ *,
394
+ allowed_keys: Collection[str] | None = None,
395
+ ) -> None:
396
+ """Check ``header`` and what is passed of the files against the format;
397
+ :class:`~extf.ExtfError` names every line that breaks it, ``line`` being the line the
398
+ field would have in its file. ``allowed_keys``: the tax keys the books have; ``None``
399
+ takes any key of up to four digits. The keys ``20`` to ``29`` are refused either way:
400
+ the reader takes them as a Generalumkehr, which :attr:`Record.reversal` states. So are
401
+ ``labels`` and a ``header`` whose written file would be valid UTF-8 beyond ASCII, which
402
+ only its text as a whole can tell: the reader would refuse it."""
403
+ errors: list[LineError] = []
404
+ _check_header(errors, header)
405
+ if records is not None:
406
+ _check_period(errors, header)
407
+ for line, record in enumerate(records, start=_FIRST_RECORD_LINE):
408
+ _check_record(errors, (CATEGORY_NAMES[BOOKINGS], line), record, header, allowed_keys)
409
+ length = header.account_length
410
+ seen: set[str] = set()
411
+ for line, label in enumerate(labels or (), start=_FIRST_RECORD_LINE):
412
+ where = (CATEGORY_NAMES[ACCOUNT_LABELS], line)
413
+ if not _is_account(label.number, (length,)):
414
+ errors.append(LineError(*where, f"account {label.number!r}: a label is for a general ledger account of {length} digits"))
415
+ if label.number in seen:
416
+ errors.append(LineError(*where, f"account {label.number} is labelled twice"))
417
+ seen.add(label.number)
418
+ if not label.label.strip():
419
+ errors.append(LineError(*where, f"account {label.number}: the label is empty"))
420
+ _check_text(errors, where, "Kontenbeschriftung", label.label, _LONG_LABEL_LENGTH)
421
+ if labels is not None:
422
+ _check_label_file(errors, header, labels)
423
+ seen = set()
424
+ for line, account in enumerate(personal or (), start=_FIRST_RECORD_LINE):
425
+ where = (CATEGORY_NAMES[PERSONAL_ACCOUNTS], line)
426
+ if not _is_account(account.number, (length + 1,)):
427
+ errors.append(LineError(*where, f"personal account {account.number!r}: a personal account has {length + 1} digits"))
428
+ if account.number in seen:
429
+ errors.append(LineError(*where, f"personal account {account.number} is listed twice"))
430
+ seen.add(account.number)
431
+ if not account.name[:_NAME_LENGTH].strip():
432
+ errors.append(LineError(*where, f"personal account {account.number}: the name is empty"))
433
+ _check_text(errors, where, "Name", account.name[:_NAME_LENGTH], _NAME_LENGTH)
434
+ if errors:
435
+ raise ExtfError(errors)
436
+
437
+
438
+ # --- writing ----------------------------------------------------------------------------
439
+
440
+
441
+ def _quoted(value: str) -> str:
442
+ return '"' + value.replace('"', '""') + '"'
443
+
444
+
445
+ def _title(header: ExportHeader, category: int) -> str:
446
+ if category != BOOKINGS:
447
+ return CATEGORY_NAMES[category]
448
+ assert header.date_from is not None and header.date_to is not None
449
+ start, end = header.date_from, header.date_to
450
+ month_end = date(start.year, start.month, calendar.monthrange(start.year, start.month)[1])
451
+ if start.day == 1 and month_end == end:
452
+ return f"Buchungen {start:%m/%Y}"
453
+ return f"Buchungen {start:%d.%m.}-{end:%d.%m.%Y}"
454
+
455
+
456
+ def _header_line(header: ExportHeader, category: int) -> str:
457
+ """Line 1 of a file (31 fields)."""
458
+ bookings = category == BOOKINGS
459
+ fields = [
460
+ _quoted("EXTF"),
461
+ str(FORMAT_VERSION),
462
+ str(category),
463
+ _quoted(CATEGORY_NAMES[category]),
464
+ str(CATEGORY_VERSIONS[category]),
465
+ f"{header.created:%Y%m%d%H%M%S}{header.created.microsecond // 1000:03d}",
466
+ "",
467
+ _quoted(header.origin),
468
+ _quoted(header.exported_by),
469
+ _quoted(""),
470
+ str(header.advisor_number),
471
+ str(header.client_number),
472
+ f"{header.fiscal_year_start:%Y%m%d}",
473
+ str(header.account_length),
474
+ f"{header.date_from:%Y%m%d}" if bookings else "",
475
+ f"{header.date_to:%Y%m%d}" if bookings else "",
476
+ _quoted(_title(header, category)),
477
+ _quoted(""),
478
+ "1" if bookings else "",
479
+ "0" if bookings else "",
480
+ ("1" if header.locked else "0") if bookings else "",
481
+ _quoted("EUR" if bookings else ""),
482
+ "",
483
+ _quoted(""),
484
+ "",
485
+ "",
486
+ _quoted(header.chart),
487
+ "",
488
+ "",
489
+ _quoted(""),
490
+ _quoted(""),
491
+ ]
492
+ return ";".join(fields)
493
+
494
+
495
+ def _encode(lines: Sequence[str]) -> bytes:
496
+ return ("\r\n".join(lines) + "\r\n").encode(DATEV_ENCODING)
497
+
498
+
499
+ def _record_line(record: Record) -> str:
500
+ cells = [""] * len(BOOKING_COLUMNS)
501
+
502
+ def put(column: str, value: str, *, text: bool = False) -> None:
503
+ cells[_COLUMN[column]] = _quoted(value) if text else value
504
+
505
+ put("Umsatz (ohne Soll/Haben-Kz)", amount_de(record.amount))
506
+ put("Soll/Haben-Kennzeichen", "S", text=True)
507
+ put("WKZ Umsatz", "EUR", text=True)
508
+ put("Konto", record.account)
509
+ put("Gegenkonto (ohne BU-Schlüssel)", record.contra_account)
510
+ put("BU-Schlüssel", record.tax_key or "", text=True)
511
+ put("Belegdatum", f"{record.booking_date:%d%m}")
512
+ put("Belegfeld 1", record.document_number or "", text=True)
513
+ put("Buchungstext", record.text, text=True)
514
+ for number, (kind, content) in enumerate(record.infos, start=1):
515
+ put(f"Beleginfo - Art {number}", kind, text=True)
516
+ put(f"Beleginfo - Inhalt {number}", content, text=True)
517
+ put("Generalumkehr (GU)", "1" if record.reversal else "0")
518
+ return ";".join(cells)
519
+
520
+
521
+ def write_bookings(header: ExportHeader, records: Sequence[Record], *, allowed_keys: Collection[str] | None = None) -> bytes:
522
+ """A Buchungsstapel (category 21), validated (:func:`validate`)."""
523
+ validate(header, records, allowed_keys=allowed_keys)
524
+ return _encode([_header_line(header, BOOKINGS), ";".join(BOOKING_COLUMNS), *(_record_line(record) for record in records)])
525
+
526
+
527
+ def _label_lines(header: ExportHeader, labels: Sequence[Label]) -> list[str]:
528
+ lines = [_header_line(header, ACCOUNT_LABELS), ";".join(LABEL_COLUMNS)]
529
+ for label in labels:
530
+ lines.append(";".join((label.number, _quoted(label.label[:_LABEL_LENGTH]), _quoted("de-DE"), _quoted(label.label))))
531
+ return lines
532
+
533
+
534
+ def write_account_labels(header: ExportHeader, labels: Sequence[Label]) -> bytes:
535
+ """Kontenbeschriftungen (category 20), validated (:func:`validate`). A label longer than
536
+ the short column is cut there; the long column keeps all of it."""
537
+ validate(header, labels=labels)
538
+ return _encode(_label_lines(header, labels))
539
+
540
+
541
+ def write_personal_accounts(header: ExportHeader, personal: Sequence[Personal]) -> bytes:
542
+ """Debitoren/Kreditoren (category 16), validated (:func:`validate`). Every account is
543
+ written as a company; a name longer than its column is cut there; the VAT id goes out
544
+ split into country and number if it is an EU one and is left out otherwise."""
545
+ validate(header, personal=personal)
546
+ lines = [_header_line(header, PERSONAL_ACCOUNTS), ";".join(PERSONAL_COLUMNS)]
547
+ for account in personal:
548
+ country, number = "", ""
549
+ match = _EU_VAT_ID.fullmatch(account.vat_id or "")
550
+ if match is not None and match.group(1) in _EU_COUNTRIES:
551
+ country, number = match.group(1), match.group(2)
552
+ cells = [
553
+ account.number,
554
+ _quoted(account.name[:_NAME_LENGTH]),
555
+ _quoted(""),
556
+ _quoted(""),
557
+ _quoted(""),
558
+ _quoted(""),
559
+ _quoted(ADDRESSEE_COMPANY),
560
+ _quoted(account.name[:_SHORT_NAME_LENGTH]),
561
+ _quoted(country),
562
+ _quoted(number),
563
+ ]
564
+ lines.append(";".join(cells))
565
+ return _encode(lines)
extf/py.typed ADDED
File without changes