c474a39466
ELDA-Fixsatz-Framework (models/elda.py) nach DM-ORG 42.7.0 Kapitel C.1/C.2+E.1-E.3: Fixlängen-Sätze ohne Feldtrennzeichen (a/a-n linksbündig, Grundstellung blank; n rechtsbündig, Grundstellung 0, führende Nullen, KEINE Interpunktion — Beträge EURO-Cent kaufmännisch gerundet, Prozentsätze x1000 mit 3 Nachkommastellen), Encoding ISO-8859-15 (Standard-Encoding für Fixlängen), optionaler Dateiheader (DHKZ 2x blank + DHVN 01 + ENCD), Vorlaufsatz SART 00 (BEST/VSTR/VERS) + Schlusssatz SART 99 mit SANZ; SANR lückenlos ab 1 je Datenbestand; CRLF als Satztrenner (Praxis-Annahme, DM-ORG normiert keines — Parallellauf verifizieren). Lohnzettel Finanz L16 (models/lohnzettel_l16.py): Informationssatz I1 (SART I1, Länge 1.100, Strukturversion 03) + Mitteilungssatz L1 (SART L1, Länge 3.500, Lohnzettelversion 28 — zwingend ab 01.03.2026) mit vollständiger, PDF-verifizierter Feldtabelle (~210 Felder inkl. Kinderblock 15x123 ab Pos. 1501; DM-ORG 42.7.0 E.13/E.14). KZ-245-Formel: KZ 210 = Bruttobezüge gem. § 25 GESAMT (DM-ORG-Feldbeschreibung) — die Formel 210-215-220-230-243 ergibt die laufende Steuerbemessung, per Konstruktion konsistent mit der GP3-§-66-Kette (Ableitung aus l10n_at_bemessung/steuerbefreiung_68/s67_abs und Regelcodes; bewusst KEIN paralleles KZ-Klassifikationsfeld). Arbeitsstättenmeldung § 34 Abs 6 ASVG: SART 45 (Länge 300, Version 02, BEST AD/VSTR ST Statistik Austria) je AN am 31.12. aus dem Dienstort-Partner (GKZ manuell, Warnung wenn fehlt) — im selben Jahreslauf (Nutzer-Entscheidung 5.1). Kind-Datenmodell (models/kind.py — l10n.at.payroll.kind): Stammdaten-Wartung des Familienbonus Plus (Nutzer-Entscheidung 5.3: FB+ rechnet NICHT im Lohnsteuerkern — bei Unterschreiten der Steuer auf 0 erfolgt die Geltendmachung über die Arbeitnehmerveranlagung); Felder für den L16-Kinderblock (Familienname/Vorname/Geburtsdatum/VSNR/Staatsangehörigkeit/FB+-Monate voll/halb, Alterstaffel 166,68/58,34). Stammdaten (models/meldewesen.py): Beitragskontonummer BKNR + Lohnsteuernummer (res.company), Gemeindekennziffer GKZ (res.partner), VSNR-Formatprüfung LLLPTTMMJJ auf hr.version.ssnid (Prüfzifferalgorithmus = dokumentierter Rest, SVS-Quelle beschaffen), eSV-Abmeldegrund AGRD-Mapping auf hr.departure.reason (Standard 00); fallweise-Beschäftigungs-Flag auf hr.version (§ 33 Abs 1a Z 1 ASVG). Belege (AP6): Lohnzettel AT (ir.actions.report auf hr.payslip, AT-Layout mit Bezügen/DN-Abzügen/DG-Anteilen/Netto), Monatsdienstliste (mBGM-Kontrollschicht je AN) + Monatsjournal (je Lohnart Summen je Lauf) auf hr.payslip.run als QWeb (models/monatsreports.py + report/belege_reports.xml; account.report wird in keinem HR-Modul genutzt). Tests: test_elda_l16 (4 — Feldformatierung/Cent-Beträge/skala, Bestands-Assembly SANR/SANZ/CRLF/ISO-8859-15/Dateiheader, L16-Lauf mit positionsexakten Assertions aus der November-Fixture, Arbeitsstätten-Satz). 19.0.4.0.0.
256 lines
11 KiB
Python
256 lines
11 KiB
Python
# 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) |