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/_reader.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""Readers of files in the DATEV format (EXTF) and of the CSV lists that come with them.
|
|
2
|
+
|
|
3
|
+
**DATEV format** (``EXTF``, format version 700): Windows-1252, fields separated by ``;``,
|
|
4
|
+
text in double quotes (``""`` inside a text is one quote), amounts with a decimal comma.
|
|
5
|
+
Line 1 is the header (category, fiscal year start, length of the general ledger account
|
|
6
|
+
numbers, period), line 2 names the columns, every further line is a record. Three
|
|
7
|
+
categories are read:
|
|
8
|
+
|
|
9
|
+
* ``21`` Buchungsstapel — booking entries (:func:`read_bookings`),
|
|
10
|
+
* ``20`` Kontenbeschriftungen — the labels of the general ledger accounts (:func:`read_account_labels`),
|
|
11
|
+
* ``16`` Debitoren/Kreditoren — the personal accounts (:func:`read_personal_accounts`;
|
|
12
|
+
the list Kanzlei-Rechnungswesen exports is read as well).
|
|
13
|
+
|
|
14
|
+
Columns are found by their name (DATEV writes the full list; only the ones needed here
|
|
15
|
+
are read), so the order of the columns does not matter.
|
|
16
|
+
|
|
17
|
+
**CSV lists** (``;``, decimal comma, UTF-8 or Windows-1252): the trial balance
|
|
18
|
+
(Summen- und Saldenliste, :func:`read_trial_balance`), the personal accounts as
|
|
19
|
+
Kanzlei-Rechnungswesen lists them (Debitoren-/Kreditorenstammdaten) and the fixed-asset register
|
|
20
|
+
(Anlagenverzeichnis, :func:`read_fixed_assets`). The header row is the first row that
|
|
21
|
+
names the column ``Konto``; lines before it (a title, the period) are skipped, within
|
|
22
|
+
:data:`MAX_HEADER_SEARCH` characters.
|
|
23
|
+
|
|
24
|
+
Every reader is strict: a file with a single bad line is refused as a whole, with every
|
|
25
|
+
bad line named (:class:`DatevFormatError`); reading stops at the 200th. Nothing is guessed.
|
|
26
|
+
|
|
27
|
+
**Input is hostile.** :data:`MAX_INPUT_BYTES`, :data:`MAX_LINES`, :data:`MAX_COLUMNS` and
|
|
28
|
+
:data:`MAX_FIELD_LENGTH` bound what is read at all, :data:`MAX_HEADER_SEARCH` what is searched
|
|
29
|
+
for the header row of a CSV list; nothing from the input is evaluated. On any bytes a reader
|
|
30
|
+
returns or raises :class:`DatevFormatError`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import csv
|
|
36
|
+
import io
|
|
37
|
+
import re
|
|
38
|
+
from collections.abc import Callable, Iterator
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import date
|
|
41
|
+
from decimal import Decimal
|
|
42
|
+
|
|
43
|
+
from extf._errors import DatevFormatError, LineError
|
|
44
|
+
from extf._format import (
|
|
45
|
+
ACCOUNT_LABELS,
|
|
46
|
+
ACCOUNT_LENGTHS,
|
|
47
|
+
AMOUNT_LIMIT,
|
|
48
|
+
BOOKINGS,
|
|
49
|
+
CATEGORY_NAMES,
|
|
50
|
+
DATEV_ENCODING,
|
|
51
|
+
DEFAULT_ACCOUNT_LENGTH,
|
|
52
|
+
MONEY_CONTEXT,
|
|
53
|
+
PERSONAL_ACCOUNTS,
|
|
54
|
+
fiscal_year_end,
|
|
55
|
+
is_reversal_key,
|
|
56
|
+
is_utf8,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
#: The size of an input in bytes above which it is refused unread.
|
|
60
|
+
MAX_INPUT_BYTES = 64 * 1024 * 1024
|
|
61
|
+
#: The lines, the columns of a line and the characters of a field a file may have.
|
|
62
|
+
MAX_LINES = 1_000_000
|
|
63
|
+
MAX_COLUMNS = 512
|
|
64
|
+
MAX_FIELD_LENGTH = 4096
|
|
65
|
+
#: The characters of a CSV list searched for its header row, the header row included. A
|
|
66
|
+
#: separator and a line end count as one each, so that empty cells use up the search as well.
|
|
67
|
+
MAX_HEADER_SEARCH = 1024 * 1024
|
|
68
|
+
#: The format versions read (the header's second field).
|
|
69
|
+
FORMAT_VERSIONS = (500, 700)
|
|
70
|
+
_MAX_ERRORS = 200
|
|
71
|
+
#: The characters of the longest row that passes both limits: every cell quoted, every character
|
|
72
|
+
#: of it a doubled quote, the separators and a line end of two. A longer row breaks one of them.
|
|
73
|
+
_MAX_ROW_CHARS = MAX_COLUMNS * (2 * MAX_FIELD_LENGTH + 3) + 1
|
|
74
|
+
_NO_LIST_HEADER = "no header row with the column Konto"
|
|
75
|
+
|
|
76
|
+
_AMOUNT = re.compile(r"^[0-9]{1,3}(?:\.?[0-9]{3})*(?:,[0-9]{1,2})?$")
|
|
77
|
+
_SIGNED_AMOUNT = re.compile(r"^-?[0-9]{1,3}(?:\.?[0-9]{3})*(?:,[0-9]{1,2})?$")
|
|
78
|
+
_DIGITS = re.compile(r"^[0-9]+$")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class Header:
|
|
83
|
+
"""Line 1 of a file in the DATEV format."""
|
|
84
|
+
|
|
85
|
+
category: int
|
|
86
|
+
format_version: int
|
|
87
|
+
#: The first day of the fiscal year (WJ-Beginn).
|
|
88
|
+
fiscal_year_start: date
|
|
89
|
+
#: The length of a general ledger account number (Sachkontenlänge).
|
|
90
|
+
account_length: int
|
|
91
|
+
#: The period of a Buchungsstapel (Datum vom / Datum bis); ``None`` for the other categories.
|
|
92
|
+
date_from: date | None
|
|
93
|
+
date_to: date | None
|
|
94
|
+
#: The Buchungsstapel is locked (Festschreibung ``1``).
|
|
95
|
+
locked: bool
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def fiscal_year(self) -> int:
|
|
99
|
+
return self.fiscal_year_start.year
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class Booking:
|
|
104
|
+
"""One record of a Buchungsstapel, with the sides resolved: ``amount`` (euros) debited
|
|
105
|
+
to ``debit_account`` and credited to ``credit_account``."""
|
|
106
|
+
|
|
107
|
+
file: str
|
|
108
|
+
line: int
|
|
109
|
+
amount: Decimal
|
|
110
|
+
debit_account: str
|
|
111
|
+
credit_account: str
|
|
112
|
+
#: The account the tax key belongs to as written: ``Gegenkonto``.
|
|
113
|
+
contra_account: str
|
|
114
|
+
#: BU-Schlüssel as written (``None``: none).
|
|
115
|
+
tax_key: str | None
|
|
116
|
+
booking_date: date
|
|
117
|
+
#: Belegfeld 1.
|
|
118
|
+
document_number: str | None
|
|
119
|
+
#: Belegfeld 2.
|
|
120
|
+
document_number_2: str | None
|
|
121
|
+
text: str | None
|
|
122
|
+
currency: str
|
|
123
|
+
foreign_amount: Decimal | None
|
|
124
|
+
fx_rate: str | None
|
|
125
|
+
#: Buchungs GUID, if DATEV wrote one: the entry's identity across deliveries.
|
|
126
|
+
guid: str | None
|
|
127
|
+
#: Festschreibung of the record (``None``: not stated).
|
|
128
|
+
locked: bool | None
|
|
129
|
+
#: Generalumkehr: the record cancels an entry. A record marked in the column
|
|
130
|
+
#: ``Generalumkehr (GU)`` carries its effective side in the export already; the sides
|
|
131
|
+
#: above are as written (see :func:`read_bookings`).
|
|
132
|
+
reversal: bool = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class BookingFile:
|
|
137
|
+
header: Header
|
|
138
|
+
bookings: list[Booking]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class AccountLabel:
|
|
143
|
+
line: int
|
|
144
|
+
number: str
|
|
145
|
+
label: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class PersonalAccount:
|
|
150
|
+
line: int
|
|
151
|
+
number: str
|
|
152
|
+
name: str
|
|
153
|
+
#: The VAT id (country code and number, no blanks), if stated.
|
|
154
|
+
vat_id: str | None
|
|
155
|
+
#: The advisor's customer or supplier number of the account (Kunden-/Lief.-Nr.), if stated.
|
|
156
|
+
customer_number: str | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class Balance:
|
|
161
|
+
"""One line of a trial balance: the balance of ``account``, debit positive."""
|
|
162
|
+
|
|
163
|
+
line: int
|
|
164
|
+
account: str
|
|
165
|
+
label: str | None
|
|
166
|
+
#: The balance at the end of the period, the opening balance included.
|
|
167
|
+
balance: Decimal
|
|
168
|
+
#: The opening balance (EB-Wert), debit positive, if the list states it. Part of
|
|
169
|
+
#: ``balance`` already: the opening entries are entries of the Buchungsstapel.
|
|
170
|
+
opening: Decimal | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class FixedAsset:
|
|
175
|
+
line: int
|
|
176
|
+
asset_number: str
|
|
177
|
+
name: str
|
|
178
|
+
account: str
|
|
179
|
+
acquired_on: date
|
|
180
|
+
acquisition_cost: Decimal
|
|
181
|
+
#: Useful life in years (``None``: not stated, e.g. land).
|
|
182
|
+
useful_life_years: int | None
|
|
183
|
+
method: str | None
|
|
184
|
+
#: The book value on the day of the register (``None``: not stated).
|
|
185
|
+
book_value: Decimal | None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# --- low level ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _norm(name: str) -> str:
|
|
192
|
+
"""A column name as compared: lower case, umlauts spelled out, only letters and digits."""
|
|
193
|
+
lowered = name.casefold().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
|
194
|
+
return re.sub(r"[^a-z0-9]", "", lowered).replace("adressatentyp", "adressattyp")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _Errors:
|
|
198
|
+
def __init__(self, file: str) -> None:
|
|
199
|
+
self.file = file
|
|
200
|
+
self.items: list[LineError] = []
|
|
201
|
+
|
|
202
|
+
def add(self, line: int, message: str) -> None:
|
|
203
|
+
"""Note a bad line; the 200th ends the reading: the file is refused by then."""
|
|
204
|
+
self.items.append(LineError(self.file, line, message))
|
|
205
|
+
if len(self.items) >= _MAX_ERRORS:
|
|
206
|
+
raise DatevFormatError(self.items) from None
|
|
207
|
+
|
|
208
|
+
def raise_any(self) -> None:
|
|
209
|
+
if self.items:
|
|
210
|
+
raise DatevFormatError(self.items)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _rows(text: str, file: str) -> Iterator[tuple[int, list[str]]]:
|
|
214
|
+
"""The rows of ``text`` with the number of the line each starts on."""
|
|
215
|
+
start = 1
|
|
216
|
+
characters = 0
|
|
217
|
+
|
|
218
|
+
def lines() -> Iterator[str]:
|
|
219
|
+
"""The lines of ``text``, refusing a row that passes the bound before csv.reader parses it."""
|
|
220
|
+
nonlocal characters
|
|
221
|
+
for line in io.StringIO(text, newline=""):
|
|
222
|
+
characters += len(line)
|
|
223
|
+
if characters > _MAX_ROW_CHARS:
|
|
224
|
+
message = f"the line has more than {MAX_COLUMNS} columns or a field of more than {MAX_FIELD_LENGTH} characters"
|
|
225
|
+
raise DatevFormatError([LineError(file, start, message)])
|
|
226
|
+
yield line
|
|
227
|
+
|
|
228
|
+
reader = csv.reader(lines(), delimiter=";", quotechar='"', strict=True)
|
|
229
|
+
try:
|
|
230
|
+
for row in reader:
|
|
231
|
+
if reader.line_num > MAX_LINES:
|
|
232
|
+
raise DatevFormatError([LineError(file, start, f"the file has more than {MAX_LINES} lines")])
|
|
233
|
+
if len(row) > MAX_COLUMNS:
|
|
234
|
+
raise DatevFormatError([LineError(file, start, f"the line has more than {MAX_COLUMNS} columns")])
|
|
235
|
+
if any(len(cell) > MAX_FIELD_LENGTH for cell in row):
|
|
236
|
+
raise DatevFormatError([LineError(file, start, f"a field has more than {MAX_FIELD_LENGTH} characters")])
|
|
237
|
+
yield start, row
|
|
238
|
+
start = reader.line_num + 1
|
|
239
|
+
characters = 0
|
|
240
|
+
except csv.Error as exc:
|
|
241
|
+
raise DatevFormatError([LineError(file, start, f"not readable as CSV: {exc}")]) from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _records(rows: Iterator[tuple[int, list[str]]]) -> Iterator[tuple[int, list[str]]]:
|
|
245
|
+
"""The rows left in ``rows`` without the blank ones."""
|
|
246
|
+
return ((line, row) for line, row in rows if any(cell.strip() for cell in row))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _check_size(data: bytes, file: str) -> None:
|
|
250
|
+
if len(data) > MAX_INPUT_BYTES:
|
|
251
|
+
raise DatevFormatError([LineError(file, 1, f"the file has {len(data)} bytes, at most {MAX_INPUT_BYTES}")])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _decode_datev(data: bytes, file: str) -> str:
|
|
255
|
+
_check_size(data, file)
|
|
256
|
+
if data.startswith(b"\xef\xbb\xbf"):
|
|
257
|
+
raise DatevFormatError(
|
|
258
|
+
[LineError(file, 1, "starts with a UTF-8 byte order mark: the DATEV format is Windows-1252; export it again")]
|
|
259
|
+
)
|
|
260
|
+
if is_utf8(data):
|
|
261
|
+
raise DatevFormatError([LineError(file, 1, "is UTF-8: the DATEV format is Windows-1252; export it again")])
|
|
262
|
+
try:
|
|
263
|
+
return data.decode(DATEV_ENCODING)
|
|
264
|
+
except UnicodeDecodeError as exc:
|
|
265
|
+
line = data[: exc.start].count(b"\n") + 1
|
|
266
|
+
raise DatevFormatError([LineError(file, line, "not Windows-1252, as the DATEV format is")]) from exc
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _decode_list(data: bytes) -> str:
|
|
270
|
+
"""A CSV list: UTF-8 (with or without BOM), else Windows-1252."""
|
|
271
|
+
try:
|
|
272
|
+
return data.decode("utf-8-sig")
|
|
273
|
+
except UnicodeDecodeError:
|
|
274
|
+
return data.decode(DATEV_ENCODING, errors="strict")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def parse_amount(value: str, *, signed: bool = False) -> Decimal:
|
|
278
|
+
"""``1234,56`` or ``1.234,56`` (``-`` in front if ``signed``) as a Decimal.
|
|
279
|
+
|
|
280
|
+
:class:`ValueError` for anything else, and for an amount of 15 digits or more before
|
|
281
|
+
the comma."""
|
|
282
|
+
text = value.strip()
|
|
283
|
+
if not (_SIGNED_AMOUNT if signed else _AMOUNT).match(text):
|
|
284
|
+
raise ValueError(f"not an amount with a decimal comma: {value[:40]!r}")
|
|
285
|
+
amount = Decimal(text.replace(".", "").replace(",", "."))
|
|
286
|
+
if abs(amount) >= AMOUNT_LIMIT:
|
|
287
|
+
raise ValueError(f"amount out of range: {text[:40]}")
|
|
288
|
+
return amount
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_day(value: str, what: str) -> date:
|
|
292
|
+
text = value.strip()
|
|
293
|
+
try:
|
|
294
|
+
if re.fullmatch(r"[0-9]{8}", text):
|
|
295
|
+
return date(int(text[:4]), int(text[4:6]), int(text[6:8]))
|
|
296
|
+
if re.fullmatch(r"[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{4}", text):
|
|
297
|
+
day, month, year = (int(part) for part in text.split("."))
|
|
298
|
+
return date(year, month, day)
|
|
299
|
+
except ValueError:
|
|
300
|
+
pass
|
|
301
|
+
raise ValueError(f"{what} is no date: {value[:40]!r}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _account(value: str, length: int) -> str:
|
|
305
|
+
"""An account number padded to the length of the general ledger accounts."""
|
|
306
|
+
text = value.strip()
|
|
307
|
+
if not _DIGITS.match(text):
|
|
308
|
+
raise ValueError(f"account {value[:40]!r} is no number")
|
|
309
|
+
text = text.zfill(length)
|
|
310
|
+
if len(text) > length + 1 or int(text) == 0:
|
|
311
|
+
raise ValueError(f"account {value[:40]!r} is no account of {length} or {length + 1} digits")
|
|
312
|
+
return text
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _cut(value: str | None, limit: int, what: str) -> str | None:
|
|
316
|
+
if value is None or not value.strip():
|
|
317
|
+
return None
|
|
318
|
+
text = value.strip()
|
|
319
|
+
if len(text) > limit:
|
|
320
|
+
raise ValueError(f"{what} has more than {limit} characters")
|
|
321
|
+
return text
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --- the DATEV format --------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _number(text: str) -> int:
|
|
328
|
+
"""A number of the header, ``-1`` if it is none."""
|
|
329
|
+
return int(text) if re.fullmatch(r"[0-9]{1,9}", text) else -1
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _header(row: list[str], file: str, category: int) -> Header:
|
|
333
|
+
def field(index: int) -> str:
|
|
334
|
+
return row[index].strip() if index < len(row) else ""
|
|
335
|
+
|
|
336
|
+
marker = field(0)
|
|
337
|
+
if marker not in ("EXTF", "DTVF"):
|
|
338
|
+
raise DatevFormatError([LineError(file, 1, "no DATEV header: line 1 starts with neither EXTF nor DTVF")])
|
|
339
|
+
errors = _Errors(file)
|
|
340
|
+
if _number(field(2)) != category:
|
|
341
|
+
name = CATEGORY_NAMES[category]
|
|
342
|
+
errors.add(1, f"format category {field(2)[:40] or '(empty)'} ({field(3)[:40] or '?'}), expected {category} ({name})")
|
|
343
|
+
errors.raise_any()
|
|
344
|
+
format_version = _number(field(1))
|
|
345
|
+
if not FORMAT_VERSIONS[0] <= format_version <= FORMAT_VERSIONS[1]:
|
|
346
|
+
errors.add(1, f"format version {field(1)[:40] or '(empty)'} is not supported ({FORMAT_VERSIONS[0]} to {FORMAT_VERSIONS[1]} are)")
|
|
347
|
+
fiscal_year_start = date(1, 1, 1)
|
|
348
|
+
try:
|
|
349
|
+
fiscal_year_start = _parse_day(field(12), "the start of the fiscal year (WJ-Beginn)")
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
errors.add(1, str(exc))
|
|
352
|
+
account_length = _number(field(13))
|
|
353
|
+
if not ACCOUNT_LENGTHS[0] <= account_length <= ACCOUNT_LENGTHS[1]:
|
|
354
|
+
errors.add(
|
|
355
|
+
1,
|
|
356
|
+
f"general ledger accounts have {field(13)[:40] or '(no)'} digits (Sachkontenlänge); "
|
|
357
|
+
f"{ACCOUNT_LENGTHS[0]} to {ACCOUNT_LENGTHS[1]} are possible",
|
|
358
|
+
)
|
|
359
|
+
date_from = date_to = None
|
|
360
|
+
if category == BOOKINGS:
|
|
361
|
+
try:
|
|
362
|
+
date_from = _parse_day(field(14), "the first day (Datum vom)")
|
|
363
|
+
date_to = _parse_day(field(15), "the last day (Datum bis)")
|
|
364
|
+
if date_to < date_from:
|
|
365
|
+
errors.add(1, "the period ends before it starts")
|
|
366
|
+
except ValueError as exc:
|
|
367
|
+
errors.add(1, str(exc))
|
|
368
|
+
errors.raise_any()
|
|
369
|
+
return Header(
|
|
370
|
+
category=category,
|
|
371
|
+
format_version=format_version,
|
|
372
|
+
fiscal_year_start=fiscal_year_start,
|
|
373
|
+
account_length=account_length,
|
|
374
|
+
date_from=date_from,
|
|
375
|
+
date_to=date_to,
|
|
376
|
+
locked=field(20) == "1",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class _Columns:
|
|
381
|
+
"""The columns of a file by their normalised name."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, names: list[str]) -> None:
|
|
384
|
+
self.names = [_norm(name) for name in names]
|
|
385
|
+
self.index: dict[str, int] = {}
|
|
386
|
+
for position, name in enumerate(self.names):
|
|
387
|
+
if name:
|
|
388
|
+
self.index.setdefault(name, position)
|
|
389
|
+
|
|
390
|
+
def find(self, *names: str) -> int | None:
|
|
391
|
+
return next((self.index[_norm(name)] for name in names if _norm(name) in self.index), None)
|
|
392
|
+
|
|
393
|
+
def marks(self, position: int | None) -> tuple[int, int] | None:
|
|
394
|
+
"""The columns ``S`` and ``H`` right after the amount column at ``position``, which
|
|
395
|
+
mark the side of its amount (Kanzlei-Rechnungswesen: ``Saldo;S;H``); ``None``:
|
|
396
|
+
there are none."""
|
|
397
|
+
if position is None or self.names[position + 1 : position + 3] != ["s", "h"]:
|
|
398
|
+
return None
|
|
399
|
+
return position + 1, position + 2
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _datev_file(data: bytes, file: str, category: int) -> tuple[Header, _Columns, Iterator[tuple[int, list[str]]]]:
|
|
403
|
+
rows = _rows(_decode_datev(data, file), file)
|
|
404
|
+
first = next(rows, None)
|
|
405
|
+
if first is None:
|
|
406
|
+
raise DatevFormatError([LineError(file, 1, "the file is empty")])
|
|
407
|
+
header = _header(first[1], file, category)
|
|
408
|
+
second = next(rows, None)
|
|
409
|
+
if second is None:
|
|
410
|
+
raise DatevFormatError([LineError(file, 2, "line 2 (the column names) is missing")])
|
|
411
|
+
return header, _Columns(second[1]), _records(rows)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _require(columns: _Columns, file: str, wanted: dict[str, tuple[str, ...]]) -> dict[str, int]:
|
|
415
|
+
found: dict[str, int] = {}
|
|
416
|
+
missing = []
|
|
417
|
+
for key, names in wanted.items():
|
|
418
|
+
position = columns.find(*names)
|
|
419
|
+
if position is None:
|
|
420
|
+
missing.append(names[0])
|
|
421
|
+
else:
|
|
422
|
+
found[key] = position
|
|
423
|
+
if missing:
|
|
424
|
+
raise DatevFormatError([LineError(file, 2, f"column(s) missing: {', '.join(missing)}")])
|
|
425
|
+
return found
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cell(row: list[str], position: int | None) -> str:
|
|
429
|
+
if position is None or position >= len(row):
|
|
430
|
+
return ""
|
|
431
|
+
return row[position].strip()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _record_day(value: str, header: Header) -> date:
|
|
435
|
+
"""Belegdatum ``DDMM`` (or ``DMM``) in the fiscal year of the Buchungsstapel (WJ-Beginn
|
|
436
|
+
and the twelve months after it), not only in its period: a late entry of an earlier
|
|
437
|
+
month is booked in the batch of a later one. The year is the one that puts the day
|
|
438
|
+
into the fiscal year — across the turn of the year of a fiscal year that is not the
|
|
439
|
+
calendar year as well."""
|
|
440
|
+
text = value.strip()
|
|
441
|
+
if not re.fullmatch(r"[0-9]{3,4}", text):
|
|
442
|
+
raise ValueError(f"Belegdatum {value!r} is not DDMM")
|
|
443
|
+
text = text.zfill(4)
|
|
444
|
+
start = header.fiscal_year_start
|
|
445
|
+
end = fiscal_year_end(start)
|
|
446
|
+
for year in (start.year, start.year + 1):
|
|
447
|
+
try:
|
|
448
|
+
day = date(year, int(text[2:]), int(text[:2]))
|
|
449
|
+
except ValueError:
|
|
450
|
+
continue
|
|
451
|
+
if start <= day <= end:
|
|
452
|
+
return day
|
|
453
|
+
raise ValueError(f"Belegdatum {text} is no day of the fiscal year {start.isoformat()} – {end.isoformat()}")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def read_bookings(data: bytes, file: str = "Buchungsstapel") -> BookingFile:
|
|
457
|
+
"""A Buchungsstapel (category 21). ``S``: ``Konto`` is debited and ``Gegenkonto``
|
|
458
|
+
credited; ``H`` the other way round. ``Umsatz`` is in ``WKZ Umsatz``; in another
|
|
459
|
+
currency than euros, ``Basis-Umsatz`` holds the euro amount.
|
|
460
|
+
|
|
461
|
+
A Generalumkehr cancels an entry. Marked in the column ``Generalumkehr (GU)`` = ``1``,
|
|
462
|
+
the record carries its effective side already — DATEV exports the cancellation with
|
|
463
|
+
the side it books — so the sides are read as written and ``reversal`` only marks it
|
|
464
|
+
(read so, the cancelled entries come out at the balances of the trial balance;
|
|
465
|
+
swapped again, they count twice). Marked the old way, by the correction digit ``2``
|
|
466
|
+
in front of the tax key (e.g. ``29``), the sides are swapped and the tax key without
|
|
467
|
+
the correction digit applies — unproven, the exports seen so far do not write it. The Belegdatum may be any day of the fiscal year (a late entry, see
|
|
468
|
+
:func:`_record_day`). A record with a ``Skonto`` amount is refused as a bad line: DATEV
|
|
469
|
+
splits the discount off automatically, which is not rebuilt here."""
|
|
470
|
+
header, columns, records = _datev_file(data, file, BOOKINGS)
|
|
471
|
+
need = _require(
|
|
472
|
+
columns,
|
|
473
|
+
file,
|
|
474
|
+
{
|
|
475
|
+
"amount": ("Umsatz (ohne Soll/Haben-Kz)", "Umsatz"),
|
|
476
|
+
"side": ("Soll/Haben-Kennzeichen", "Soll-/Haben-Kennzeichen"),
|
|
477
|
+
"account": ("Konto",),
|
|
478
|
+
"contra": ("Gegenkonto (ohne BU-Schlüssel)", "Gegenkonto"),
|
|
479
|
+
"day": ("Belegdatum",),
|
|
480
|
+
},
|
|
481
|
+
)
|
|
482
|
+
optional = {
|
|
483
|
+
"currency": columns.find("WKZ Umsatz"),
|
|
484
|
+
"rate": columns.find("Kurs"),
|
|
485
|
+
"base": columns.find("Basis-Umsatz"),
|
|
486
|
+
"base_currency": columns.find("WKZ Basis-Umsatz"),
|
|
487
|
+
"key": columns.find("BU-Schlüssel"),
|
|
488
|
+
"number": columns.find("Belegfeld 1"),
|
|
489
|
+
"number_2": columns.find("Belegfeld 2"),
|
|
490
|
+
"text": columns.find("Buchungstext"),
|
|
491
|
+
"locked": columns.find("Festschreibung"),
|
|
492
|
+
"guid": columns.find("Buchungs GUID"),
|
|
493
|
+
"reversal": columns.find("Generalumkehr (GU)", "Generalumkehr"),
|
|
494
|
+
"discount": columns.find("Skonto"),
|
|
495
|
+
}
|
|
496
|
+
errors = _Errors(file)
|
|
497
|
+
bookings: list[Booking] = []
|
|
498
|
+
for line, row in records:
|
|
499
|
+
try:
|
|
500
|
+
bookings.append(_booking(file, line, row, header, need, optional))
|
|
501
|
+
except ValueError as exc:
|
|
502
|
+
errors.add(line, str(exc))
|
|
503
|
+
errors.raise_any()
|
|
504
|
+
return BookingFile(header=header, bookings=bookings)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _booking(
|
|
508
|
+
file: str, line: int, row: list[str], header: Header, need: dict[str, int], optional: dict[str, int | None]
|
|
509
|
+
) -> Booking:
|
|
510
|
+
length = header.account_length
|
|
511
|
+
amount = parse_amount(_cell(row, need["amount"]))
|
|
512
|
+
if amount <= 0:
|
|
513
|
+
raise ValueError("Umsatz must be above zero")
|
|
514
|
+
side = _cell(row, need["side"]).upper()
|
|
515
|
+
if side not in ("S", "H"):
|
|
516
|
+
raise ValueError(f"Soll/Haben-Kennzeichen {side or '(empty)'} is neither S nor H")
|
|
517
|
+
account = _account(_cell(row, need["account"]), length)
|
|
518
|
+
contra = _account(_cell(row, need["contra"]), length)
|
|
519
|
+
if account == contra:
|
|
520
|
+
raise ValueError(f"Konto and Gegenkonto are both {account}")
|
|
521
|
+
key = _cell(row, optional["key"]) or None
|
|
522
|
+
if key is not None and not re.fullmatch(r"[0-9]{1,4}", key):
|
|
523
|
+
raise ValueError(f"BU-Schlüssel {key!r} is no key")
|
|
524
|
+
reversal_text = _cell(row, optional["reversal"])
|
|
525
|
+
if reversal_text not in ("", "0", "1"):
|
|
526
|
+
raise ValueError(f"Generalumkehr {reversal_text!r} is neither 0 nor 1")
|
|
527
|
+
reversal = reversal_text == "1"
|
|
528
|
+
swapped = False
|
|
529
|
+
if key is not None and is_reversal_key(key):
|
|
530
|
+
# The old way of writing a Generalumkehr: correction digit 2 in front of the tax key.
|
|
531
|
+
if reversal:
|
|
532
|
+
raise ValueError(f"BU-Schlüssel {key} and the column Generalumkehr both mark a Generalumkehr")
|
|
533
|
+
reversal, swapped, key = True, True, (None if key[1] == "0" else key[1])
|
|
534
|
+
discount = _cell(row, optional["discount"])
|
|
535
|
+
if discount and parse_amount(discount) != 0:
|
|
536
|
+
raise ValueError(
|
|
537
|
+
"Skonto is not supported (DATEV splits the discount off automatically); "
|
|
538
|
+
"export the discount as entries of their own"
|
|
539
|
+
)
|
|
540
|
+
currency = (_cell(row, optional["currency"]) or "EUR").upper()
|
|
541
|
+
foreign_amount: Decimal | None = None
|
|
542
|
+
fx_rate: str | None = None
|
|
543
|
+
if currency != "EUR":
|
|
544
|
+
if not re.fullmatch(r"[A-Z]{3}", currency):
|
|
545
|
+
raise ValueError(f"WKZ Umsatz {currency!r} is no currency")
|
|
546
|
+
base_currency = (_cell(row, optional["base_currency"]) or "EUR").upper()
|
|
547
|
+
base = _cell(row, optional["base"])
|
|
548
|
+
if base_currency != "EUR" or not base:
|
|
549
|
+
raise ValueError(f"an amount in {currency} needs its euro amount (Basis-Umsatz in EUR)")
|
|
550
|
+
foreign_amount, amount = amount, parse_amount(base)
|
|
551
|
+
if amount <= 0:
|
|
552
|
+
raise ValueError("Basis-Umsatz must be above zero")
|
|
553
|
+
rate = _cell(row, optional["rate"])
|
|
554
|
+
fx_rate = rate.replace(",", ".") if rate else None
|
|
555
|
+
if fx_rate is not None and not re.fullmatch(r"[0-9]{1,6}(\.[0-9]{1,6})?", fx_rate):
|
|
556
|
+
raise ValueError(f"Kurs {rate!r} is no rate")
|
|
557
|
+
debit, credit = (account, contra) if (side == "S") != swapped else (contra, account)
|
|
558
|
+
locked = _cell(row, optional["locked"])
|
|
559
|
+
return Booking(
|
|
560
|
+
file=file,
|
|
561
|
+
line=line,
|
|
562
|
+
amount=amount,
|
|
563
|
+
debit_account=debit,
|
|
564
|
+
credit_account=credit,
|
|
565
|
+
contra_account=contra,
|
|
566
|
+
tax_key=key,
|
|
567
|
+
booking_date=_record_day(_cell(row, need["day"]), header),
|
|
568
|
+
document_number=_cut(_cell(row, optional["number"]), 36, "Belegfeld 1"),
|
|
569
|
+
document_number_2=_cut(_cell(row, optional["number_2"]), 12, "Belegfeld 2"),
|
|
570
|
+
text=_cut(_cell(row, optional["text"]), 60, "Buchungstext"),
|
|
571
|
+
currency=currency,
|
|
572
|
+
foreign_amount=foreign_amount,
|
|
573
|
+
fx_rate=fx_rate,
|
|
574
|
+
guid=_cut(_cell(row, optional["guid"]), 36, "Buchungs GUID"),
|
|
575
|
+
locked=None if not locked else locked == "1",
|
|
576
|
+
reversal=reversal,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def read_account_labels(data: bytes, file: str = "Kontenbeschriftungen") -> tuple[Header, list[AccountLabel]]:
|
|
581
|
+
"""Kontenbeschriftungen (category 20): general ledger accounts and their labels (the
|
|
582
|
+
long label where there is one)."""
|
|
583
|
+
header, columns, records = _datev_file(data, file, ACCOUNT_LABELS)
|
|
584
|
+
need = _require(columns, file, {"account": ("Konto",), "label": ("Kontenbeschriftung",)})
|
|
585
|
+
long_label = columns.find("Kontenbeschriftung lang")
|
|
586
|
+
errors = _Errors(file)
|
|
587
|
+
labels: list[AccountLabel] = []
|
|
588
|
+
seen: dict[str, int] = {}
|
|
589
|
+
for line, row in records:
|
|
590
|
+
try:
|
|
591
|
+
number = _account(_cell(row, need["account"]), header.account_length)
|
|
592
|
+
if len(number) != header.account_length:
|
|
593
|
+
raise ValueError(f"account {number} is no general ledger account ({header.account_length} digits)")
|
|
594
|
+
label = _cut(_cell(row, long_label) or _cell(row, need["label"]), 300, "the label")
|
|
595
|
+
if label is None:
|
|
596
|
+
raise ValueError(f"account {number} has no label")
|
|
597
|
+
if number in seen:
|
|
598
|
+
raise ValueError(f"account {number} is labelled in line {seen[number]} already")
|
|
599
|
+
seen[number] = line
|
|
600
|
+
labels.append(AccountLabel(line=line, number=number, label=label))
|
|
601
|
+
except ValueError as exc:
|
|
602
|
+
errors.add(line, str(exc))
|
|
603
|
+
errors.raise_any()
|
|
604
|
+
return header, labels
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def is_datev_format(data: bytes) -> bool:
|
|
608
|
+
"""Whether ``data`` starts with the header of the DATEV format (``EXTF`` / ``DTVF``)."""
|
|
609
|
+
first = data[:16].lstrip(b"\xef\xbb\xbf")[:6].lstrip(b'"')
|
|
610
|
+
return first.startswith((b"EXTF", b"DTVF"))
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def read_personal_accounts(
|
|
614
|
+
data: bytes, file: str = "Debitoren/Kreditoren", *, account_length: int = DEFAULT_ACCOUNT_LENGTH
|
|
615
|
+
) -> tuple[Header | None, list[PersonalAccount]]:
|
|
616
|
+
"""Debitoren/Kreditoren: the personal accounts with name and VAT id — the DATEV format
|
|
617
|
+
(category 16), or the list Kanzlei-Rechnungswesen exports (Debitoren-/Kreditoren-
|
|
618
|
+
stammdaten: a title line, then ``Konto;K/I;Beschriftung;…;USt-IdNr.;…;Kunden-/Lief.-Nr.``;
|
|
619
|
+
no header of the DATEV format, ``None`` for it, so ``account_length`` says how long
|
|
620
|
+
a general ledger account is there). A list names a person as ``Nachname, Vorname``;
|
|
621
|
+
the name is kept as written."""
|
|
622
|
+
if not is_datev_format(data):
|
|
623
|
+
return None, _read_personal_list(data, file, account_length)
|
|
624
|
+
header, columns, records = _datev_file(data, file, PERSONAL_ACCOUNTS)
|
|
625
|
+
need = _require(columns, file, {"account": ("Konto",)})
|
|
626
|
+
company = columns.find("Name (Adressattyp Unternehmen)")
|
|
627
|
+
surname = columns.find("Name (Adressattyp natürl. Person)")
|
|
628
|
+
first_name = columns.find("Vorname (Adressattyp natürl. Person)")
|
|
629
|
+
unspecified = columns.find("Name (Adressattyp keine Angabe)")
|
|
630
|
+
short = columns.find("Kurzbezeichnung")
|
|
631
|
+
country = columns.find("EU-Land", "EU-Mitgliedstaat")
|
|
632
|
+
vat_number = columns.find("EU-UStID", "EU-USt-IdNr.", "EU-UStIdNr")
|
|
633
|
+
errors = _Errors(file)
|
|
634
|
+
accounts: list[PersonalAccount] = []
|
|
635
|
+
seen: dict[str, int] = {}
|
|
636
|
+
for line, row in records:
|
|
637
|
+
try:
|
|
638
|
+
number = _account(_cell(row, need["account"]), header.account_length)
|
|
639
|
+
if len(number) != header.account_length + 1:
|
|
640
|
+
raise ValueError(f"account {number} is no personal account ({header.account_length + 1} digits)")
|
|
641
|
+
person = " ".join(part for part in (_cell(row, first_name), _cell(row, surname)) if part)
|
|
642
|
+
name = _cell(row, company) or person or _cell(row, unspecified) or _cell(row, short)
|
|
643
|
+
if not name:
|
|
644
|
+
raise ValueError(f"account {number} has no name")
|
|
645
|
+
if number in seen:
|
|
646
|
+
raise ValueError(f"account {number} is listed in line {seen[number]} already")
|
|
647
|
+
seen[number] = line
|
|
648
|
+
vat = re.sub(r"\s", "", _cell(row, country) + _cell(row, vat_number)).upper() or None
|
|
649
|
+
if vat is not None and len(vat) > 20:
|
|
650
|
+
raise ValueError("the VAT id has more than 20 characters")
|
|
651
|
+
if len(name) > 200:
|
|
652
|
+
raise ValueError("the name has more than 200 characters")
|
|
653
|
+
accounts.append(PersonalAccount(line=line, number=number, name=name, vat_id=vat))
|
|
654
|
+
except ValueError as exc:
|
|
655
|
+
errors.add(line, str(exc))
|
|
656
|
+
errors.raise_any()
|
|
657
|
+
return header, accounts
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# --- CSV lists ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _list_rows(data: bytes, file: str) -> tuple[_Columns, Iterator[tuple[int, list[str]]]]:
|
|
664
|
+
_check_size(data, file)
|
|
665
|
+
try:
|
|
666
|
+
text = _decode_list(data)
|
|
667
|
+
except UnicodeDecodeError as exc:
|
|
668
|
+
raise DatevFormatError([LineError(file, 1, "neither UTF-8 nor Windows-1252")]) from exc
|
|
669
|
+
rows = _rows(text, file)
|
|
670
|
+
searched = 0
|
|
671
|
+
for _line, row in rows:
|
|
672
|
+
searched += len(";".join(row)) + 1
|
|
673
|
+
if searched > MAX_HEADER_SEARCH:
|
|
674
|
+
break
|
|
675
|
+
if any(_norm(cell) == "konto" for cell in row):
|
|
676
|
+
return _Columns(row), _records(rows)
|
|
677
|
+
raise DatevFormatError([LineError(file, 1, _NO_LIST_HEADER)])
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _read_list[T](data: bytes, file: str, parse: Callable[[_Columns, str], Callable[[int, list[str]], T]]) -> list[T]:
|
|
681
|
+
columns, records = _list_rows(data, file)
|
|
682
|
+
one = parse(columns, file)
|
|
683
|
+
errors = _Errors(file)
|
|
684
|
+
result: list[T] = []
|
|
685
|
+
for line, row in records:
|
|
686
|
+
try:
|
|
687
|
+
result.append(one(line, row))
|
|
688
|
+
except ValueError as exc:
|
|
689
|
+
errors.add(line, str(exc))
|
|
690
|
+
errors.raise_any()
|
|
691
|
+
return result
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _marked(row: list[str], position: int, marks: tuple[int, int], what: str) -> Decimal:
|
|
695
|
+
"""The amount at ``position`` with its side from the columns ``S`` and ``H`` after it:
|
|
696
|
+
exactly one of them marks a non-zero amount; an empty amount is zero and has no mark."""
|
|
697
|
+
text = _cell(row, position)
|
|
698
|
+
debit_mark, credit_mark = _cell(row, marks[0]).upper(), _cell(row, marks[1]).upper()
|
|
699
|
+
if debit_mark not in ("", "S") or credit_mark not in ("", "H"):
|
|
700
|
+
raise ValueError(f"{what}: the side marks {debit_mark!r} / {credit_mark!r} are not S / H")
|
|
701
|
+
if not text:
|
|
702
|
+
if debit_mark or credit_mark:
|
|
703
|
+
raise ValueError(f"{what}: a side mark without an amount")
|
|
704
|
+
return Decimal(0)
|
|
705
|
+
amount = parse_amount(text)
|
|
706
|
+
if debit_mark and credit_mark:
|
|
707
|
+
raise ValueError(f"{what} {text} is marked S and H")
|
|
708
|
+
if not debit_mark and not credit_mark:
|
|
709
|
+
if amount == 0:
|
|
710
|
+
return amount
|
|
711
|
+
raise ValueError(f"{what} {text} is marked neither S nor H")
|
|
712
|
+
return amount if debit_mark else amount.copy_negate()
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def read_trial_balance(
|
|
716
|
+
data: bytes, file: str = "Summen- und Saldenliste", *, account_length: int = DEFAULT_ACCOUNT_LENGTH
|
|
717
|
+
) -> list[Balance]:
|
|
718
|
+
"""A trial balance (Summen- und Saldenliste) as CSV: ``Konto``, optionally
|
|
719
|
+
``Beschriftung``, and the balance as ``Saldo Soll`` / ``Saldo Haben`` or as ``Saldo``
|
|
720
|
+
— signed, with an ``S/H`` column, or with its side in the columns ``S`` and ``H``
|
|
721
|
+
right after it, as Kanzlei-Rechnungswesen exports it (``EB-Wert;S;H;Saldo;S;H;…``,
|
|
722
|
+
the opening balance ``EB-Wert`` marked the same way). Debit is positive. ``Saldo``
|
|
723
|
+
is the balance at the end of the period, the opening balance included. Rows without
|
|
724
|
+
an account number are the totals of the export (per account class, the grand total)
|
|
725
|
+
and are skipped. The list has no header: ``account_length`` says how long a general
|
|
726
|
+
ledger account is, and shorter numbers are padded to it."""
|
|
727
|
+
|
|
728
|
+
def parse(columns: _Columns, file: str) -> Callable[[int, list[str]], Balance | None]:
|
|
729
|
+
account = columns.find("Konto")
|
|
730
|
+
label = columns.find("Beschriftung", "Kontenbeschriftung", "Bezeichnung")
|
|
731
|
+
debit, credit = columns.find("Saldo Soll"), columns.find("Saldo Haben")
|
|
732
|
+
signed, side = columns.find("Saldo"), columns.find("S/H", "Soll/Haben")
|
|
733
|
+
marks = columns.marks(signed)
|
|
734
|
+
opening = columns.find("EB-Wert")
|
|
735
|
+
opening_marks = columns.marks(opening)
|
|
736
|
+
if (debit is None or credit is None) and signed is None:
|
|
737
|
+
raise DatevFormatError([LineError(file, 1, "columns missing: Saldo Soll and Saldo Haben, or Saldo")])
|
|
738
|
+
seen: dict[str, int] = {}
|
|
739
|
+
|
|
740
|
+
def one(line: int, row: list[str]) -> Balance | None:
|
|
741
|
+
if not _cell(row, account):
|
|
742
|
+
# A total row (account class, grand total) of the export: no account of its own.
|
|
743
|
+
return None
|
|
744
|
+
number = _account(_cell(row, account), account_length)
|
|
745
|
+
if debit is not None and credit is not None:
|
|
746
|
+
debit_text, credit_text = _cell(row, debit), _cell(row, credit)
|
|
747
|
+
if debit_text and credit_text and parse_amount(debit_text) and parse_amount(credit_text):
|
|
748
|
+
raise ValueError(f"account {number} has a debit and a credit balance")
|
|
749
|
+
balance = MONEY_CONTEXT.subtract(
|
|
750
|
+
parse_amount(debit_text) if debit_text else Decimal(0),
|
|
751
|
+
parse_amount(credit_text) if credit_text else Decimal(0),
|
|
752
|
+
)
|
|
753
|
+
elif marks is not None:
|
|
754
|
+
assert signed is not None
|
|
755
|
+
balance = _marked(row, signed, marks, f"Saldo of account {number}")
|
|
756
|
+
else:
|
|
757
|
+
balance = parse_amount(_cell(row, signed) or "0", signed=True)
|
|
758
|
+
marker = _cell(row, side).upper()
|
|
759
|
+
if marker not in ("", "S", "H"):
|
|
760
|
+
raise ValueError(f"S/H {marker!r} is neither S nor H")
|
|
761
|
+
if marker == "H":
|
|
762
|
+
balance = balance.copy_negate()
|
|
763
|
+
opening_balance = None
|
|
764
|
+
if opening is not None and opening_marks is not None:
|
|
765
|
+
opening_balance = _marked(row, opening, opening_marks, f"EB-Wert of account {number}")
|
|
766
|
+
if number in seen:
|
|
767
|
+
raise ValueError(f"account {number} is listed in line {seen[number]} already")
|
|
768
|
+
seen[number] = line
|
|
769
|
+
return Balance(
|
|
770
|
+
line=line,
|
|
771
|
+
account=number,
|
|
772
|
+
label=_cut(_cell(row, label), 200, "the label"),
|
|
773
|
+
balance=balance,
|
|
774
|
+
opening=opening_balance,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
return one
|
|
778
|
+
|
|
779
|
+
return [balance for balance in _read_list(data, file, parse) if balance is not None]
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _read_personal_list(data: bytes, file: str, account_length: int) -> list[PersonalAccount]:
|
|
783
|
+
"""The personal accounts as a list (see :func:`read_personal_accounts`)."""
|
|
784
|
+
|
|
785
|
+
def parse(columns: _Columns, file: str) -> Callable[[int, list[str]], PersonalAccount]:
|
|
786
|
+
need = _require(columns, file, {"account": ("Konto",), "name": ("Beschriftung", "Name")})
|
|
787
|
+
vat_id = columns.find("USt-IdNr.", "USt-IdNr", "EU-UStID")
|
|
788
|
+
short = columns.find("Kurzbezeichnung")
|
|
789
|
+
number_column = columns.find("Kunden-/Lief.-Nr.", "Kunden-/Lieferantennummer")
|
|
790
|
+
seen: dict[str, int] = {}
|
|
791
|
+
|
|
792
|
+
def one(line: int, row: list[str]) -> PersonalAccount:
|
|
793
|
+
number = _account(_cell(row, need["account"]), account_length)
|
|
794
|
+
if len(number) != account_length + 1:
|
|
795
|
+
raise ValueError(f"account {number} is no personal account ({account_length + 1} digits)")
|
|
796
|
+
name = _cell(row, need["name"]) or _cell(row, short)
|
|
797
|
+
if not name:
|
|
798
|
+
raise ValueError(f"account {number} has no name")
|
|
799
|
+
if number in seen:
|
|
800
|
+
raise ValueError(f"account {number} is listed in line {seen[number]} already")
|
|
801
|
+
seen[number] = line
|
|
802
|
+
vat = re.sub(r"\s", "", _cell(row, vat_id)).upper() or None
|
|
803
|
+
if vat is not None and len(vat) > 20:
|
|
804
|
+
raise ValueError("the VAT id has more than 20 characters")
|
|
805
|
+
if len(name) > 200:
|
|
806
|
+
raise ValueError("the name has more than 200 characters")
|
|
807
|
+
return PersonalAccount(
|
|
808
|
+
line=line,
|
|
809
|
+
number=number,
|
|
810
|
+
name=name,
|
|
811
|
+
vat_id=vat,
|
|
812
|
+
customer_number=_cut(_cell(row, number_column), 50, "Kunden-/Lief.-Nr."),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
return one
|
|
816
|
+
|
|
817
|
+
try:
|
|
818
|
+
return _read_list(data, file, parse)
|
|
819
|
+
except DatevFormatError as exc:
|
|
820
|
+
if [error.message for error in exc.errors] == [_NO_LIST_HEADER]:
|
|
821
|
+
message = f"no DATEV header (line 1 starts with neither EXTF nor DTVF) and {_NO_LIST_HEADER}"
|
|
822
|
+
raise DatevFormatError([LineError(file, 1, message)]) from exc
|
|
823
|
+
raise
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def read_fixed_assets(
|
|
827
|
+
data: bytes, file: str = "Anlagenverzeichnis", *, account_length: int = DEFAULT_ACCOUNT_LENGTH
|
|
828
|
+
) -> list[FixedAsset]:
|
|
829
|
+
"""A fixed-asset register (Anlagenverzeichnis) as CSV: ``Inventarnummer``,
|
|
830
|
+
``Bezeichnung``, ``Konto``, ``Anschaffungsdatum`` (``DD.MM.YYYY``),
|
|
831
|
+
``Anschaffungskosten``, optionally ``Nutzungsdauer`` (years), ``AfA-Art`` and
|
|
832
|
+
``Buchwert``. The list has no header: ``account_length`` says how long a general ledger
|
|
833
|
+
account is."""
|
|
834
|
+
|
|
835
|
+
def parse(columns: _Columns, file: str) -> Callable[[int, list[str]], FixedAsset]:
|
|
836
|
+
wanted = {
|
|
837
|
+
"number": ("Inventarnummer", "Anlagennummer", "Anlage"),
|
|
838
|
+
"name": ("Bezeichnung", "Anlagenbezeichnung"),
|
|
839
|
+
"account": ("Konto",),
|
|
840
|
+
"acquired": ("Anschaffungsdatum", "AHK-Datum", "Zugangsdatum"),
|
|
841
|
+
"cost": ("Anschaffungskosten", "AHK", "Anschaffungs-/Herstellungskosten"),
|
|
842
|
+
}
|
|
843
|
+
need = _require(columns, file, wanted)
|
|
844
|
+
life = columns.find("Nutzungsdauer", "ND")
|
|
845
|
+
method = columns.find("AfA-Art", "AfA-Methode")
|
|
846
|
+
book_value = columns.find("Buchwert", "Restbuchwert")
|
|
847
|
+
seen: dict[str, int] = {}
|
|
848
|
+
|
|
849
|
+
def one(line: int, row: list[str]) -> FixedAsset:
|
|
850
|
+
number = _cut(_cell(row, need["number"]), 50, "the asset number")
|
|
851
|
+
if number is None:
|
|
852
|
+
raise ValueError("the asset has no number")
|
|
853
|
+
if number in seen:
|
|
854
|
+
raise ValueError(f"asset {number} is listed in line {seen[number]} already")
|
|
855
|
+
name = _cut(_cell(row, need["name"]), 200, "the name")
|
|
856
|
+
if name is None:
|
|
857
|
+
raise ValueError(f"asset {number} has no name")
|
|
858
|
+
account = _account(_cell(row, need["account"]), account_length)
|
|
859
|
+
if len(account) != account_length:
|
|
860
|
+
raise ValueError(f"account {account} is no general ledger account")
|
|
861
|
+
life_text = _cell(row, life)
|
|
862
|
+
if life_text and not re.fullmatch(r"[0-9]{1,3}", life_text):
|
|
863
|
+
raise ValueError(f"Nutzungsdauer {life_text!r} is no number of years")
|
|
864
|
+
value_text = _cell(row, book_value)
|
|
865
|
+
seen[number] = line
|
|
866
|
+
return FixedAsset(
|
|
867
|
+
line=line,
|
|
868
|
+
asset_number=number,
|
|
869
|
+
name=name,
|
|
870
|
+
account=account,
|
|
871
|
+
acquired_on=_parse_day(_cell(row, need["acquired"]), "Anschaffungsdatum"),
|
|
872
|
+
acquisition_cost=parse_amount(_cell(row, need["cost"])),
|
|
873
|
+
useful_life_years=int(life_text) if life_text else None,
|
|
874
|
+
method=_cut(_cell(row, method), 50, "AfA-Art"),
|
|
875
|
+
book_value=parse_amount(value_text) if value_text else None,
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
return one
|
|
879
|
+
|
|
880
|
+
return _read_list(data, file, parse)
|