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/__init__.py +106 -0
- extf/_errors.py +33 -0
- extf/_format.py +65 -0
- extf/_reader.py +880 -0
- extf/_writer.py +565 -0
- extf/py.typed +0 -0
- extf-0.1.0.dist-info/METADATA +168 -0
- extf-0.1.0.dist-info/RECORD +12 -0
- extf-0.1.0.dist-info/WHEEL +5 -0
- extf-0.1.0.dist-info/licenses/LICENSE +202 -0
- extf-0.1.0.dist-info/licenses/NOTICE +9 -0
- extf-0.1.0.dist-info/top_level.txt +1 -0
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
|