Files
odoo-at-payroll/addons/l10n_at_hr_payroll/models/elda.py
T
fegger c474a39466 [IMP] l10n_at_hr_payroll: GP7 ELDA-Fixsatz-Framework, L16 + Arbeitsstättenmeldung, Kind-Modell, Belege (AP5/AP6)
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.
2026-09-12 17:30:59 +02:00

256 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.1E.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. 175177, 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)