# Part of the odoo-at-payroll project. License: LGPL-3. """ELDA-Fixsatz-Framework (GP7/AP5) — „Datenaustausch mit Dienstgebern" (DM-ORG 42.7.0, 07/2026; lokal `.firecrawl/ris/elda/`, unversioniert). Dateikonventionen (DM-ORG Kapitel C.1/C.2, E.1–E.3 — verifiziert 2026-09-12): - Datei = optionaler Dateiheader (DHKZ 2×blank + DHVN 01 + ENCD) + mindestens ein Datenbestand; Datenbestand = Vorlaufsatz (SART 00, Feld VERS = Version der nachfolgenden Satzstrukturen) + Datensätze + Schlusssatz (SART 99, SANZ = Satzanzahl inkl. Vorlauf und Schluss). - Fixlängen-Sätze ohne Feldtrennzeichen; alle Sätze eines Bestands nehmen die maximale Satzlänge des Bestands an (RESE-Auffüllung). Sätze werden zeilenweise mit CRLF getrennt (die DM-ORG normiert KEIN explizites Satztrennzeichen — CRLF ist die Praxis der ELDA-Kanäle; Annahme im Parallellauf gegen den Mandanten-ELDA-Zugang verifizieren). - ``a``/``a/n``: linksbündig, Grundstellung blank; ``n``: rechtsbündig, Grundstellung 0, führende Nullen, KEINE Interpunktion (Beträge als EURO-Cent, kaufmännisch gerundet; Prozentsätze mit impliziten Nachkommastellen via ``skala``). - Encoding Fixlängen-Dateien: ISO-8859-15 (Standard-Encoding; der Dateiheader deklariert ENCD). Vorzeichen gehören in eigene V-Felder (RSVZ/Vxxx), ``n``-Felder sind nie negativ. """ from datetime import date, datetime from decimal import ROUND_HALF_UP, Decimal L10N_AT_ELDA_ENCODING = 'ISO-8859-15' L10N_AT_ELDA_ZEILENTRENNER = '\r\n' # Identifikationsteil (E.1, S. 174) — 20 Stellen, alle Satzarten gleich; # alle Angaben zwingend. L10N_AT_ELDA_IDTEIL = [ (1, 'SART', 1, 2, 'a'), # Satzart (2, 'SANR', 3, 7, 'n'), # laufende Satznummer je Bestand, ab 1 (3, 'UVST', 10, 2, 'a'), # datenübernehmender Träger (ED = ELDA) (4, 'OBUS', 12, 7, 'n'), # Ordnungsbegriff der übermittelnden Stelle (5, 'VSTR', 19, 2, 'a'), # zuständiger Träger (D.4) ] # Vorlaufsatz-Datenteil (E.2, S. 175–177, Version 08) — folgt dem IDTEIL; # zwingend außer HTEL/SOID/VNMF; Satzlänge = maximale Satzlänge des Bestands. L10N_AT_ELDA_VORLAUF = [ (2, 'PROJ', 21, 2, 'a'), # DM; TM = Testdaten (3, 'BEST', 23, 2, 'a'), # Verarbeitung (MB/VR/LF/AD/MA …, E.2) (4, 'DTNR', 25, 6, 'a'), # Datenträgernummer (TP-Übermittlung: frei) (5, 'EDAT', 31, 8, 'n'), # TTMMJJJJ (6, 'EZEI', 39, 6, 'n'), # HHMMSS (7, 'HRST', 45, 45, 'a'), # Herstellername (8, 'HKFZ', 90, 3, 'a'), # Internationales Kfz-Kennzeichen (A) (9, 'HPLZ', 93, 7, 'a'), (10, 'HORT', 100, 20, 'a'), (11, 'HSTR', 120, 30, 'a'), (12, 'VERS', 150, 2, 'n'), # Version der Satzstrukturen (D.26) (13, 'HTEL', 152, 20, 'a'), (14, 'SOID', 172, 70, 'a'), (15, 'VNMF', 242, 5, 'a'), # Mitteilungsfile-Version ] # Schlusssatz-Datenteil (E.3, S. 178, Version 01) — nach IDTEIL; # SANZ zwingend, ELNR/HEMA optional. L10N_AT_ELDA_SCHLUSS = [ (2, 'SANZ', 21, 6, 'n'), # Satzanzahl inkl. Vorlauf- und Schlusssatz (3, 'ELNR', 27, 6, 'n'), (4, 'HEMA', 33, 60, 'a'), ] def l10n_at_euro_cent(betrag): """EURO-Betrag → Cent (kaufmännisch gerundet; DM-ORG: keine Interpunktion).""" return int((Decimal(str(betrag)) * 100).quantize( Decimal('1'), rounding=ROUND_HALF_UP)) class L10nAtEldaSatz: """Ein Fixlängen-Satz: Felddaten (Nr, Name, Pos, Länge, Typ), zwingende Grundstellung je Typ, Rendering auf die Bestands-Satzlänge. Kein ORM — reine Bau-Hilfe der Meldungsgeneratoren (DmfA-Muster: Datensatz-Baum im Python-Teil, Validierung/Prüfprotokoll darüber).""" def __init__(self, struktur, satzlaenge, bezeichnung=''): self.struktur = list(struktur) self.satzlaenge = satzlaenge self.bezeichnung = bezeichnung self._felder = {f[1]: f for f in self.struktur} self._werte = {} if self.struktur: letzte = max(f[2] + f[3] - 1 for f in self.struktur) if letzte > satzlaenge: raise ValueError( 'Struktur %s endet bei Position %d — Satzlänge %d ' 'überschritten' % (bezeichnung, letzte, satzlaenge)) def setze(self, name, wert, cent=False, skala=0): """Feld setzen; ``wert`` None übernimmt die Grundstellung. ``cent``: EURO-Betrag → Cent; ``skala``: implizite Nachkommastellen (z. B. Prozentsatz mit 3 Nachkommastellen → skala=3).""" if wert is None: return self nr, fname, pos, laenge, typ = self._felder[name] if cent: wert = l10n_at_euro_cent(wert) if isinstance(wert, float): wert = int((Decimal(str(wert)) * (10 ** skala)).quantize( Decimal('1'), rounding=ROUND_HALF_UP)) elif isinstance(wert, int): wert = wert * (10 ** skala) roh = str(wert) if typ == 'n': if not roh.isdigit(): raise ValueError('Feld %s (%s): %r ist nicht numerisch' % ( name, self.bezeichnung, roh)) roh = roh.rjust(laenge, '0') else: roh = roh.ljust(laenge, ' ') if len(roh) > laenge: raise ValueError('Feld %s (%s): %r länger als %d' % ( name, self.bezeichnung, roh, laenge)) self._werte[name] = roh return self def setze_datum(self, name, wert, fmt='TTMMJJJJ'): """Datum in DM-ORG-Feldformat (Vorlaufsatz EDAT = TTMMJJJJ, L16-Übermittlungsfelder = JJJJMMTT, LZP/AGBD = TTMMJJ).""" if wert is None: return self if isinstance(wert, str): wert = datetime.strptime(wert, '%Y-%m-%d').date() formate = {'TTMMJJJJ': '%d%m%Y', 'JJJJMMTT': '%Y%m%d', 'TTMMJJ': '%d%m%y'} if fmt not in formate: raise ValueError('Unbekanntes Datumsformat %s' % fmt) return self.setze(name, wert.strftime(formate[fmt])) def setze_zeit(self, name, wert): if wert is None: return self return self.setze(name, wert.strftime('%H%M%S')) def ist_gesetzt(self, name): return name in self._werte def wert(self, name): return self._werte.get(name) def render(self): """Satz in Bestands-Satzlänge; unbelegte Felder in Grundstellung (``a`` blank / ``n`` 0), Positionen nach dem letzten Feld blank.""" puffer = [' '] * self.satzlaenge for nr, name, pos, laenge, typ in self.struktur: roh = self._werte.get(name) if roh is None: roh = '0' * laenge if typ == 'n' else ' ' * laenge puffer[pos - 1:pos - 1 + laenge] = roh return ''.join(puffer) class L10nAtEldaBestand: """Datenbestand: Vorlaufsatz + Datensätze + Schlusssatz zu EINER Verarbeitung (BEST) für EINEN zuständigen Träger (VSTR). SANR zählt lückenlos ab 1, SANZ inklusive Vorlauf/Schluss (Prüfvorschrift C.1.2); alle Sätze nehmen die maximale Satzlänge der Datensätze an.""" def __init__(self, best, vstr, vers, hersteller=None, test=False, obus='0000000', datensaetze=None): self.best = best self.vstr = vstr self.vers = vers self.hersteller = hersteller or {} self.projekt = 'TM' if test else 'DM' self.obus = obus self.datensaetze = datensaetze or [] def ergaenze(self, satz): self.datensaetze.append(satz) return satz def render(self, erstellungsdatum=None): """Zeilen des Bestands (ohne Zeilentrenner).""" if not self.datensaetze: raise ValueError('Datenbestand %s ohne Datensätze' % self.best) heute = erstellungsdatum or date.today() satzlaenge = max(s.satzlaenge for s in self.datensaetze) # Der Vorlaufsatz nimmt die Satzlänge des Bestands an — aber # nie kürzer als seine eigene Struktur (DM-ORG E.2: Vorlauf # trägt alle Herstellfelder; reale Bestände sind ≥ 246). satzlaenge = max( satzlaenge, max(f[2] + f[3] - 1 for f in (L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF))) zeilen = [] vorlauf_struktur = [ f for f in (L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF) if f[2] + f[3] - 1 <= satzlaenge] vorlauf = L10nAtEldaSatz( vorlauf_struktur, satzlaenge, bezeichnung='Vorlaufsatz %s' % self.best) vorlauf.setze('SART', '00') vorlauf.setze('SANR', 1) vorlauf.setze('UVST', 'ED') vorlauf.setze('OBUS', self.obus) vorlauf.setze('VSTR', self.vstr) vorlauf.setze('PROJ', self.projekt) vorlauf.setze('BEST', self.best) vorlauf.setze_datum('EDAT', heute, 'TTMMJJJJ') vorlauf.setze_zeit('EZEI', datetime.now()) vorlauf.setze('HRST', (self.hersteller.get('name') or '')[:45]) vorlauf.setze('HKFZ', self.hersteller.get('kfz', 'A')) vorlauf.setze('HPLZ', self.hersteller.get('plz')) vorlauf.setze('HORT', self.hersteller.get('ort')) vorlauf.setze('HSTR', self.hersteller.get('strasse')) vorlauf.setze('VERS', self.vers) vorlauf.setze('VNMF', '3.0') zeilen.append(vorlauf.render()) for nummer, satz in enumerate(self.datensaetze, start=2): # Datensatz-Generatoren setzen ihre SART selbst; der Bestand # ergänzt Trägerangaben (falls offen) und die laufende # Satznummer (C.1.2: lückenlos ab 1). if not satz.ist_gesetzt('UVST'): satz.setze('UVST', 'ED') if not satz.ist_gesetzt('OBUS'): satz.setze('OBUS', self.obus) if not satz.ist_gesetzt('VSTR'): satz.setze('VSTR', self.vstr) satz._werte['SANR'] = str(nummer).rjust(7, '0') zeilen.append(satz.render()) schluss = L10nAtEldaSatz( L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_SCHLUSS, satzlaenge, bezeichnung='Schlusssatz %s' % self.best) schluss.setze('SART', '99') schluss.setze('SANR', len(self.datensaetze) + 2) schluss.setze('UVST', 'ED') schluss.setze('OBUS', self.obus) schluss.setze('VSTR', self.vstr) schluss.setze('SANZ', len(self.datensaetze) + 2) zeilen.append(schluss.render()) return zeilen class L10nAtEldaDatei: """ELDA-Datei: Dateiheader (ENCD-Standard ISO-8859-15) + Datenbestände; Rendering in Bytes (Encoding + CRLF-Zeilentrenner, letzte Zeile mit Abschluss-Trenner).""" def __init__(self, bestaende=None, header=True): self.bestaende = bestaende or [] self.header = header def render(self): zeilen = [] if self.header: zeilen.append(' 01' + L10N_AT_ELDA_ENCODING.ljust(50)) for bestand in self.bestaende: zeilen.extend(bestand.render()) roh = L10N_AT_ELDA_ZEILENTRENNER.join(zeilen) \ + L10N_AT_ELDA_ZEILENTRENNER return roh.encode(L10N_AT_ELDA_ENCODING)