mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user