Lexware Buchhalter Datev-Import-Format

Moin.
Ich muß Buchhaltungsdateien aus LibraS in Lexware Buchhaltung Plus importieren.
LibraS liefert die Dateien mit dem Datev-Format 510 aus und diese werden beim Import vom Lexware verweigert. LW selbst exportiert im Format 700.
Ja, ich kann die Exportdateien aus Libras händisch mit dem Header und den Spaltentiteln vom 700er Lexware versehen und dann bekomme ich die Daten auch rein, das ist dann aber nur eine Notlösung.
Jetzt meine Frage:
Ist das ein Bug oder ein Feature, daß LW keine 510er Dateien importiert?
Danke!

Hallo,

im DATEV-Format ist für die Versionsnummer im Header zwar nur noch 700 vorgesehen, DATEV schreibt in der Feldbeschreibung dazu aber: „Anhand der Versionsnummer können ältere Versionen abwärtskompatibel verarbeitet werden.“ Das Format verlangt die Ablehnung einer 510er-Datei also nicht.

Dein Handgriff trifft dabei die richtige Zuordnung. Ich habe eine echte 510er-Datei (Formatversion 7) mit der aktuellen Musterdatei aus dem DATEV Developer Portal verglichen:

  • Der Header hat in beiden Fassungen dieselben 31 Felder in derselben Reihenfolge. Es ändern sich nur Feld 2 (510 auf 700) und Feld 5, die Formatversion (7 auf 13).
  • Die Buchungszeilen haben in 510 116 Spalten, in 700/13 sind es 125. Die neun neuen hängen hinten dran, Feld 117 „Fälligkeit“ bis Feld 125 „Abw. Skontokonto“. Keine Spalte ist umgezogen.
  • In den ersten 116 Spalten sind nur ein paar Überschriften anders geschrieben, etwa „Mandatsreferenz“ zu „SEPA-Mandatsreferenz“.

Wenn deine LibraS-Datei auch 116 Spalten hat, passt die Zuordnung eins zu eins. Feld 6 im Header (Erzeugt am) muss allerdings exakt 17 Stellen haben, YYYYMMDDHHMMSSFFF. Manche Fremdexporte schreiben dort 20 und fliegen schon deshalb raus.

Ich habe mir dafür ein Python-Skript geschrieben, das die Spalten über die Überschriften zuordnet und abbricht, statt zu raten, wenn etwas nicht passt. Wenn du es gebrauchen kannst, sag Bescheid.

Gruß

Moin
und danke für die sehr ausführliche und - wie ich finde - fundierte Antwort und Erhellung.
LibraS exportiert tatsächlich 117 Spalten mit 510 / 7, Feld 6 hat 17 Stellen, deswegen bin ich immer noch überrascht, daß das nicht paßt.
Die einfache Änderung von 510 → 700 und 7 → 13 hatte keinen Erfolg, es brauchte auch zwingend die 700er Spaltentitel.
Der Export war bisher in ISO anstelle UTF-8, aber daran sollte es ja nun eigentlich nicht scheitern, ist jetzt aber umgestellt.
Ich komm leider nur per Fernwartung an den PC, wenn jemand im Geschäft ist.
Das Phyton-Script interessiert mich brennend :slight_smile:

Danke und Gruß
Jürgen

Hallo Jürgen,

hier ist das Skript. Es steht komplett unten im aufklappbaren Kasten. Du speicherst es als datev510to700.py und brauchst nur Python 3, sonst nichts.

python3 datev510to700.py EXPORT.csv -o EXPORT_700.csv

Ohne -o prüft es die Datei nur und schreibt nichts. Es ordnet jede Spalte über ihre Überschrift zu und schreibt die komplette 700er-Überschriftszeile, was zu deiner Beobachtung passt, dass Lexware ohne die neuen Spaltentitel nicht will. Die 117. Spalte kennt meine Vergleichsdatei nicht. Ist sie bei dir unbeschriftet oder nur ein Semikolon am Zeilenende, läuft es durch. Trägt sie eine Überschrift, die das Skript nicht kennt, bricht es ab und sagt dir welche. Dann schick mir die Überschriftszeile und ich kann sie nachtragen. UTF-8 mit oder ohne BOM und ISO liest es alle drei, raus kommt ISO/cp1252, so wie DATEV es als Standard beschreibt.

Und weil du nur per Fernwartung an den Rechner kommst: Das Skript braucht kein Lexware. Du kannst die Exportdatei auf deinem eigenen Rechner umwandeln und nur die fertige 700er-Datei auf den Lexware-Rechner übertragen.

Gruß

datev510to700.py
#!/usr/bin/env python3
"""
datev510to700.py — konvertiert einen DATEV-Buchungsstapel aus dem aelteren
DATEV-Format (Versionsnummer 510, Formatversion 7) in das aktuelle Format
(Versionsnummer 700, Formatversion 13).

Standard-Bibliothek, keine Abhaengigkeiten. Python 3.8+.

    python3 datev510to700.py EXPORT.csv -o EXPORT_700.csv

Quellen (DATEV Developer Portal, "DATEV-Format", abgerufen 2026-09-11):
  - Kopfzeile, Satzaufbau (31 Felder, Feld 2 = Versionsnummer, Feld 5 = Formatversion)
  - Buchungsstapel, Formatversion 13 (125 Spalten), Musterdatei EXTF_Buchungsstapel.csv
  - Changelog: 700 ist seit Oktober 2018 die Hauptversion; seither wurden nur
    Felder ANGEHAENGT (121/122 in V11, 123/124 in V12, 125 in V13).
  - Zeichensatz: ISO-8859-1 / CodePage 1252 ist der Default; Unicode nur mit BOM.

Grundsatz: das Skript raet nicht. Was es nicht eindeutig zuordnen kann, bricht es
mit einer benannten Fehlermeldung ab, statt eine plausibel aussehende, falsche
Datei zu schreiben.
"""

from __future__ import annotations

import argparse
import re
import sys
import unicodedata

TARGET_VERSIONSNUMMER = "700"
TARGET_FORMATVERSION = "13"
FORMATKATEGORIE_BUCHUNGSSTAPEL = "21"
HEADER_FIELD_COUNT = 31

# Spaltenueberschriften Buchungsstapel, Formatversion 13 — wortgleich aus der
# Musterdatei des DATEV Developer Portals uebernommen.
CANONICAL_TITLES = [
    'Umsatz (ohne Soll/Haben-Kz)',  # 1
    'Soll/Haben-Kennzeichen',  # 2
    'WKZ Umsatz',  # 3
    'Kurs',  # 4
    'Basis-Umsatz',  # 5
    'WKZ Basis-Umsatz',  # 6
    'Konto',  # 7
    'Gegenkonto (ohne BU-Schlüssel)',  # 8
    'BU-Schlüssel',  # 9
    'Belegdatum',  # 10
    'Belegfeld 1',  # 11
    'Belegfeld 2',  # 12
    'Skonto',  # 13
    'Buchungstext',  # 14
    'Postensperre',  # 15
    'Diverse Adressnummer',  # 16
    'Geschäftspartnerbank',  # 17
    'Sachverhalt',  # 18
    'Zinssperre',  # 19
    'Beleglink',  # 20
    'Beleginfo - Art 1',  # 21
    'Beleginfo - Inhalt 1',  # 22
    'Beleginfo - Art 2',  # 23
    'Beleginfo - Inhalt 2',  # 24
    'Beleginfo - Art 3',  # 25
    'Beleginfo - Inhalt 3',  # 26
    'Beleginfo - Art 4',  # 27
    'Beleginfo - Inhalt 4',  # 28
    'Beleginfo - Art 5',  # 29
    'Beleginfo - Inhalt 5',  # 30
    'Beleginfo - Art 6',  # 31
    'Beleginfo - Inhalt 6',  # 32
    'Beleginfo - Art 7',  # 33
    'Beleginfo - Inhalt 7',  # 34
    'Beleginfo - Art 8',  # 35
    'Beleginfo - Inhalt 8',  # 36
    'KOST1 - Kostenstelle',  # 37
    'KOST2 - Kostenstelle',  # 38
    'Kost-Menge',  # 39
    'EU-Land u. UStID (Bestimmung)',  # 40
    'EU-Steuersatz (Bestimmung)',  # 41
    'Abw. Versteuerungsart',  # 42
    'Sachverhalt L+L',  # 43
    'Funktionsergänzung L+L',  # 44
    'BU 49 Hauptfunktionstyp',  # 45
    'BU 49 Hauptfunktionsnummer',  # 46
    'BU 49 Funktionsergänzung',  # 47
    'Zusatzinformation - Art 1',  # 48
    'Zusatzinformation- Inhalt 1',  # 49
    'Zusatzinformation - Art 2',  # 50
    'Zusatzinformation- Inhalt 2',  # 51
    'Zusatzinformation - Art 3',  # 52
    'Zusatzinformation- Inhalt 3',  # 53
    'Zusatzinformation - Art 4',  # 54
    'Zusatzinformation- Inhalt 4',  # 55
    'Zusatzinformation - Art 5',  # 56
    'Zusatzinformation- Inhalt 5',  # 57
    'Zusatzinformation - Art 6',  # 58
    'Zusatzinformation- Inhalt 6',  # 59
    'Zusatzinformation - Art 7',  # 60
    'Zusatzinformation- Inhalt 7',  # 61
    'Zusatzinformation - Art 8',  # 62
    'Zusatzinformation- Inhalt 8',  # 63
    'Zusatzinformation - Art 9',  # 64
    'Zusatzinformation- Inhalt 9',  # 65
    'Zusatzinformation - Art 10',  # 66
    'Zusatzinformation- Inhalt 10',  # 67
    'Zusatzinformation - Art 11',  # 68
    'Zusatzinformation- Inhalt 11',  # 69
    'Zusatzinformation - Art 12',  # 70
    'Zusatzinformation- Inhalt 12',  # 71
    'Zusatzinformation - Art 13',  # 72
    'Zusatzinformation- Inhalt 13',  # 73
    'Zusatzinformation - Art 14',  # 74
    'Zusatzinformation- Inhalt 14',  # 75
    'Zusatzinformation - Art 15',  # 76
    'Zusatzinformation- Inhalt 15',  # 77
    'Zusatzinformation - Art 16',  # 78
    'Zusatzinformation- Inhalt 16',  # 79
    'Zusatzinformation - Art 17',  # 80
    'Zusatzinformation- Inhalt 17',  # 81
    'Zusatzinformation - Art 18',  # 82
    'Zusatzinformation- Inhalt 18',  # 83
    'Zusatzinformation - Art 19',  # 84
    'Zusatzinformation- Inhalt 19',  # 85
    'Zusatzinformation - Art 20',  # 86
    'Zusatzinformation- Inhalt 20',  # 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
]
assert len(CANONICAL_TITLES) == 125, len(CANONICAL_TITLES)

# Ueberschriften, die in aelteren Staepeln anders hiessen und sich nicht allein
# durch Normalisierung (Gross-/Kleinschreibung, Leerzeichen, Bindestriche)
# aufloesen lassen. Links die alte Schreibweise, rechts die heutige.
TITLE_ALIASES = {
    "EU-Land u. UStID": "EU-Land u. UStID (Bestimmung)",
    "EU-Steuersatz": "EU-Steuersatz (Bestimmung)",
    "Mandatsreferenz": "SEPA-Mandatsreferenz",
}

# "Leerfeld" ist keine Spaltenbezeichnung, sondern ein Platzhalter fuer eine
# damals reservierte Spalte (z. B. Position 103, heute "Buchungs GUID"). Solche
# Spalten werden wie unbenannte behandelt und ueber ihre Position zugeordnet.
PLACEHOLDER_TITLES = {"leerfeld", "reserviert", "reserviertesfeld"}

ERZEUGT_AM_RE = re.compile(r"^\d{17}$")
SACHKONTENLAENGE_RE = re.compile(r"^[4-8]$")


class DatevFormatError(Exception):
    """Die Eingabedatei ist nicht das, wofuer das Skript sie halten muesste."""


def normalise_title(title: str) -> str:
    """Auf den Kern reduzieren: Gross-/Kleinschreibung, Leerzeichen, Satzzeichen."""
    t = unicodedata.normalize("NFC", title).casefold()
    return re.sub(r"[^0-9a-z\u00e0-\u00ff]+", "", t)


CANONICAL_BY_NORM = {}
for _idx, _title in enumerate(CANONICAL_TITLES):
    CANONICAL_BY_NORM.setdefault(normalise_title(_title), _idx)
for _old, _new in TITLE_ALIASES.items():
    CANONICAL_BY_NORM.setdefault(normalise_title(_old), CANONICAL_TITLES.index(_new))


# --------------------------------------------------------------------------
# CSV mit erhaltener Anfuehrungszeichen-Setzung
# --------------------------------------------------------------------------
# Das DATEV-Format unterscheidet quotierte Text- von unquotierten Zahlfeldern:
# Feld 2 einer Buchungszeile muss ^["](S|H)["]$ erfuellen, das Betragsfeld 1
# dagegen darf keine Anfuehrungszeichen tragen. Ein naiver Re-Export mit
# csv.QUOTE_MINIMAL wuerde die Quotierung neu erfinden und die Datei brechen.
# Deshalb merkt sich der Parser pro Zelle, ob sie quotiert war.

class Cell:
    __slots__ = ("value", "quoted")

    def __init__(self, value: str, quoted: bool):
        self.value = value
        self.quoted = quoted

    def render(self) -> str:
        if not self.quoted:
            return self.value
        return '"' + self.value.replace('"', '""') + '"'

    def __repr__(self):
        return f"Cell({self.value!r}, quoted={self.quoted})"


EMPTY_CELL = Cell("", False)


def split_line(line: str) -> list:
    """Eine DATEV-CSV-Zeile in Zellen zerlegen und die Quotierung mitfuehren."""
    cells = []
    i, n = 0, len(line)
    while True:
        if i < n and line[i] == '"':
            i += 1
            buf = []
            while True:
                if i >= n:
                    raise DatevFormatError(
                        "Zeile endet mitten in einem quotierten Feld: " + line[:80]
                    )
                ch = line[i]
                if ch == '"':
                    if i + 1 < n and line[i + 1] == '"':
                        buf.append('"')
                        i += 2
                        continue
                    i += 1
                    break
                buf.append(ch)
                i += 1
            cells.append(Cell("".join(buf), True))
            if i < n and line[i] != ";":
                raise DatevFormatError(
                    "Nach einem quotierten Feld folgt kein Semikolon: " + line[:80]
                )
        else:
            j = line.find(";", i)
            if j == -1:
                cells.append(Cell(line[i:], False))
                i = n
            else:
                cells.append(Cell(line[i:j], False))
                i = j
        if i >= n:
            break
        if line[i] != ";":
            raise DatevFormatError(
                "Unerwartetes Zeichen %r an Position %d der Zeile: %s"
                % (line[i], i, line[:80])
            )
        i += 1  # Semikolon ueberspringen
        if i >= n:
            cells.append(EMPTY_CELL)  # abschliessendes Semikolon = leere Zelle
            break
    return cells


def join_line(cells) -> str:
    return ";".join(c.render() for c in cells)


# --------------------------------------------------------------------------
# Einlesen
# --------------------------------------------------------------------------

BOMS = [
    (b"\xef\xbb\xbf", "utf-8-sig"),
    (b"\xff\xfe\x00\x00", "utf-32"),
    (b"\x00\x00\xfe\xff", "utf-32"),
    (b"\xff\xfe", "utf-16"),
    (b"\xfe\xff", "utf-16"),
]


def decode(raw: bytes, forced: str = None):
    if forced:
        return raw.decode(forced), forced
    for bom, enc in BOMS:
        if raw.startswith(bom):
            return raw.decode(enc), enc
    # Ohne BOM: gueltiges UTF-8 mit Nicht-ASCII-Zeichen ist praktisch nie
    # zufaellig cp1252 (Umlaute in cp1252 ergeben kein gueltiges UTF-8), also
    # wird es als UTF-8 gelesen. Alles andere: ISO-8859-1 / CodePage 1252,
    # der DATEV-Default.
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        return raw.decode("cp1252"), "cp1252"
    if any(ord(ch) > 127 for ch in text):
        return text, "utf-8 (ohne BOM)"
    return text, "cp1252"


def read_lines(path: str, forced: str = None):
    with open(path, "rb") as fh:
        raw = fh.read()
    if not raw.strip():
        raise DatevFormatError("Die Datei ist leer: " + path)
    try:
        text, enc = decode(raw, forced)
    except UnicodeDecodeError as exc:
        raise DatevFormatError(
            "Die Datei laesst sich nicht als %s lesen (%s). Mit --encoding-in "
            "die richtige Kodierung angeben." % (forced or "cp1252", exc)
        )
    lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    while lines and lines[-1] == "":
        lines.pop()
    if len(lines) < 2:
        raise DatevFormatError(
            "Erwartet werden mindestens Kopfzeile und Ueberschriftszeile, "
            "gefunden: %d Zeile(n)." % len(lines)
        )
    return lines, enc


# --------------------------------------------------------------------------
# Kopfzeile
# --------------------------------------------------------------------------

def convert_header(cells, repair_timestamp: bool, report: list):
    if len(cells) != HEADER_FIELD_COUNT:
        raise DatevFormatError(
            "Die Kopfzeile hat %d Felder, das DATEV-Format verlangt %d. Das ist "
            "kein Buchungsstapel im DATEV-Format (oder eine sehr alte Fassung)."
            % (len(cells), HEADER_FIELD_COUNT)
        )

    kennzeichen = cells[0].value
    if kennzeichen not in ("EXTF", "DTVF"):
        raise DatevFormatError(
            "Feld 1 der Kopfzeile ist %r, erwartet wird \"EXTF\" oder \"DTVF\"."
            % kennzeichen
        )

    kategorie = cells[2].value
    if kategorie != FORMATKATEGORIE_BUCHUNGSSTAPEL:
        raise DatevFormatError(
            "Feld 3 (Formatkategorie) ist %r. Dieses Skript konvertiert nur "
            "Buchungsstapel (21)." % kategorie
        )

    formatname = cells[3].value
    if formatname != "Buchungsstapel":
        raise DatevFormatError(
            "Feld 4 (Formatname) ist %r, erwartet wird \"Buchungsstapel\"." % formatname
        )

    version = cells[1].value
    formatversion = cells[4].value
    if version == TARGET_VERSIONSNUMMER and formatversion == TARGET_FORMATVERSION:
        raise DatevFormatError(
            "Die Datei ist bereits Versionsnummer 700 / Formatversion 13. Es gibt "
            "nichts zu konvertieren."
        )
    report.append("Kopfzeile: Versionsnummer %s -> %s, Formatversion %s -> %s"
                  % (version, TARGET_VERSIONSNUMMER, formatversion, TARGET_FORMATVERSION))

    erzeugt = cells[5].value
    if not ERZEUGT_AM_RE.match(erzeugt):
        if not repair_timestamp:
            raise DatevFormatError(
                "Feld 6 (Erzeugt am) ist %r. Das DATEV-Format verlangt genau 17 "
                "Stellen im Format YYYYMMDDHHMMSSFFF. Mit --repair-timestamp wird "
                "der Wert auf 17 Stellen gebracht; sonst bricht das Skript hier ab, "
                "weil geraten werden muesste." % erzeugt
            )
        digits = re.sub(r"\D", "", erzeugt)
        if len(digits) < 14:
            raise DatevFormatError(
                "Feld 6 (Erzeugt am) enthaelt nur %d Ziffern (%r). Daraus laesst "
                "sich kein Zeitstempel YYYYMMDDHHMMSSFFF bilden."
                % (len(digits), erzeugt)
            )
        fixed = (digits[:17] if len(digits) >= 17 else digits[:14].ljust(17, "0"))
        report.append("Feld 6 (Erzeugt am) repariert: %s -> %s" % (erzeugt, fixed))
        cells[5] = Cell(fixed, cells[5].quoted)

    laenge = cells[13].value
    if not SACHKONTENLAENGE_RE.match(laenge):
        raise DatevFormatError(
            "Feld 14 (Sachkontenlaenge) ist %r, zulaessig sind 4 bis 8. Der Wert "
            "muss ausserdem zur Einstellung des Mandanten passen." % laenge
        )

    cells[1] = Cell(TARGET_VERSIONSNUMMER, cells[1].quoted)
    cells[4] = Cell(TARGET_FORMATVERSION, cells[4].quoted)
    return cells


# --------------------------------------------------------------------------
# Spaltenzuordnung
# --------------------------------------------------------------------------

def build_mapping(title_cells, report: list):
    """Quellspalte -> Zielspalte, hergeleitet aus der Ueberschriftszeile der Datei.

    Benannte Spalten werden ueber ihren Namen zugeordnet, nicht ueber ihre
    Position. Verschiebt sich dabei etwas, wird es umgesetzt und gemeldet.
    Unbenannte oder als Platzhalter markierte Spalten werden ueber ihre Position
    zugeordnet. Ein Name, der sich nicht aufloesen laesst, bricht ab.
    """
    n = len(title_cells)
    if n > len(CANONICAL_TITLES):
        raise DatevFormatError(
            "Die Ueberschriftszeile hat %d Spalten. Formatversion 13 hat %d. Eine "
            "breitere Datei kann nicht verlustfrei abgebildet werden."
            % (n, len(CANONICAL_TITLES))
        )

    mapping = {}
    taken = {}
    positional = 0
    moved = []
    for src, cell in enumerate(title_cells):
        title = cell.value.strip()
        norm = normalise_title(title)
        if not norm or norm in PLACEHOLDER_TITLES:
            dst = src
            positional += 1
        else:
            dst = CANONICAL_BY_NORM.get(norm)
            if dst is None:
                raise DatevFormatError(
                    "Spalte %d heisst %r. Diese Ueberschrift kommt in Formatversion "
                    "13 nicht vor und ist auch keine bekannte aeltere Schreibweise. "
                    "Das Skript ordnet sie nicht auf Verdacht zu." % (src + 1, title)
                )
            if dst != src:
                moved.append((src + 1, dst + 1, title, CANONICAL_TITLES[dst]))
        if dst in taken:
            raise DatevFormatError(
                "Die Spalten %d (%r) und %d (%r) zeigen beide auf Zielspalte %d "
                "(%r)." % (taken[dst] + 1, title_cells[taken[dst]].value,
                           src + 1, title, dst + 1, CANONICAL_TITLES[dst])
            )
        taken[dst] = src
        mapping[src] = dst

    report.append("Spalten: %d -> %d (%d ueber die Ueberschrift zugeordnet, "
                  "%d ueber die Position)"
                  % (n, len(CANONICAL_TITLES), n - positional, positional))
    for src, dst, old, new in moved:
        report.append("  Spalte verschoben: %d %r -> %d %r" % (src, old, dst, new))
    if not moved:
        report.append("  Keine Spalte hat die Position gewechselt.")
    return mapping


def remap(cells, mapping, width):
    out = [EMPTY_CELL] * width
    for src, cell in enumerate(cells):
        out[mapping[src]] = cell
    return out


# --------------------------------------------------------------------------
# Konvertierung
# --------------------------------------------------------------------------

def convert(lines, repair_timestamp: bool):
    report = []
    header = convert_header(split_line(lines[0]), repair_timestamp, report)

    title_cells = split_line(lines[1])
    mapping = build_mapping(title_cells, report)
    width = len(CANONICAL_TITLES)
    expected = len(title_cells)

    out_titles = [Cell(t, False) for t in CANONICAL_TITLES]

    out_rows = []
    trailing = 0
    for lineno, line in enumerate(lines[2:], start=3):
        if line == "":
            continue
        cells = split_line(line)
        if (len(cells) == expected + 1 and cells[-1].value == ""
                and not cells[-1].quoted):
            # Ein abschliessendes Semikolon erzeugt ein leeres Feld hinter der
            # letzten Spalte. Das ist kein Betrag, der verrutschen kann.
            cells = cells[:-1]
            trailing += 1
        if len(cells) != expected:
            raise DatevFormatError(
                "Zeile %d hat %d Felder, die Ueberschriftszeile hat %d. Das Skript "
                "fuellt nicht stillschweigend auf, weil dabei Betraege in die "
                "falsche Spalte rutschen koennen." % (lineno, len(cells), expected)
            )
        out_rows.append(remap(cells, mapping, width))

    report.append("Buchungszeilen uebernommen: %d" % len(out_rows))
    if trailing:
        report.append("  %d Zeile(n) endeten auf ein leeres Feld hinter der letzten "
                      "Spalte (abschliessendes Semikolon); es wurde entfernt." % trailing)
    out_lines = [join_line(header), join_line(out_titles)]
    out_lines.extend(join_line(r) for r in out_rows)
    return out_lines, report


def write(path: str, lines, encoding: str):
    text = "\r\n".join(lines) + "\r\n"
    try:
        data = text.encode(encoding)
    except UnicodeEncodeError as exc:
        raise DatevFormatError(
            "Die Datei enthaelt Zeichen, die sich nicht als %s speichern lassen "
            "(%s). Mit --encoding-out utf-8-sig laesst sich das umgehen; DATEV "
            "liest UTF-8 nur mit BOM und nicht auf jedem Weg."
            % (encoding, exc)
        )
    with open(path, "wb") as fh:
        fh.write(data)


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(
        description="DATEV-Buchungsstapel von Versionsnummer 510 auf 700 "
                    "(Formatversion 13) umstellen.")
    ap.add_argument("input", help="Quelldatei (DATEV-Format, Buchungsstapel)")
    ap.add_argument("-o", "--output", help="Zieldatei; ohne Angabe nur pruefen")
    ap.add_argument("--repair-timestamp", action="store_true",
                    help="Feld 6 der Kopfzeile auf 17 Stellen bringen")
    ap.add_argument("--encoding-in", default=None,
                    help="Kodierung der Quelldatei erzwingen (Default: BOM, sonst gueltiges UTF-8, sonst cp1252)")
    ap.add_argument("--encoding-out", default="cp1252",
                    help="Kodierung der Zieldatei (Default: cp1252)")
    args = ap.parse_args(argv)

    try:
        lines, enc_in = read_lines(args.input, args.encoding_in)
        out_lines, report = convert(lines, args.repair_timestamp)
    except DatevFormatError as exc:
        sys.stderr.write("Abbruch: %s\n" % exc)
        return 2

    print("Quelle: %s (%s)" % (args.input, enc_in))
    for line in report:
        print(line)

    if args.output:
        try:
            write(args.output, out_lines, args.encoding_out)
        except DatevFormatError as exc:
            sys.stderr.write("Abbruch: %s\n" % exc)
            return 2
        print("Geschrieben: %s (%s, CRLF)" % (args.output, args.encoding_out))
    else:
        print("Nur geprueft, nichts geschrieben (keine Zieldatei angegeben).")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Kurzer Hinweis: das Script wurde von mir geprüft. Es sind keine Netzwerkzugriffe oder Datenweitergaben im Hintergrund integriert. Es werden nur Standardbibliotheken benutzt. Das kritischste Verhalten hier ist, dass es Daten lokal schreibt, aber dies muss es, ist immerhin Sinn und Zweck des Tools. :slight_smile: Es scheint also unbedenklich.

Hallo Christo,
Es sind tatsächlich nur 116 Spalten …
Das Script funktioniert super für mich, ich habe es jetzt in einer batch.
Vielen herzlichen Dank und Grüße!
Jürgen

Hallo Jürgen, das freut mich, danke für die Rückmeldung! Viele Grüße, Christo