diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py index fbaf03a..c8e5df2 100644 --- a/addons/l10n_at_hr_payroll/__manifest__.py +++ b/addons/l10n_at_hr_payroll/__manifest__.py @@ -1,17 +1,18 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)', - 'version': '19.0.3.1.0', + 'version': '19.0.4.0.0', 'author': 'ixsol', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', + 'version': '19.0.4.0.0', 'description': """ Österreichischer Personalverrechnungs-Kern ========================================== Sektor-neutraler Bundes-Kern der österreichischen Personalverrechnung auf der Odoo-19 ``hr_payroll``-Engine. Wird konsumiert von -``l10n_at_gemeinde_payroll`` (Bgld. GemBG) und später vom General-AT- +``l10n_at_gemeinde_payroll`` (Bgld. GemBG) und vom General-AT- Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/`` (PLAN-payroll-bgld.md, RECHTSQUELLEN-Bgld.md, PLAN-repo-split.md). @@ -32,8 +33,27 @@ Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/`` ``rule_parameters_sv_2026.xml``; BV-Beitrag Abfertigung Neu (BMSVG § 6 Abs 1, ``at_sv_bv_dg``) als Parameter für das General-AT-Modul (GP5); +- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework + (``models/elda.py`` — Vorlauf/Schluss, SANR/SANZ, Cent-Formatter, + ISO-8859-15), **Lohnzettel Finanz L16 + Arbeitsstättenmeldung** + (``models/lohnzettel_l16.py`` — I1/L1 nach DM-ORG 42.7.0, + Lohnzettelversion 28, KZ-245-Formel-Prüfung, BEST LF/94 bzw. AD/ST), + Kind-Datenmodell für die FB+-Wartung (``models/kind.py``), + Meldewesen-Stammdaten (BKNR/Lohnsteuernummer, GKZ am Partner, + VSNR-Formatprüfung, eSV-Abmeldegrund-Mapping, + ``models/meldewesen.py``), Belege Lohnzettel AT/Monatsdienstliste/ + Monatsjournal (QWeb, ``models/monatsreports.py``); - deklarative TASY-Mapping-Tabelle und Import-Wizard (Preview, Sprungwarnung > 10 %, explizite Bestätigung, versionierte Werte); +- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework + (``models/elda.py``), Lohnzettel Finanz L16 + Arbeitsstättenmeldung + (``models/lohnzettel_l16.py``, DM-ORG 42.7.0, PDF-verifizierte + Satzstrukturen), Kind-Datenmodell für die FB+-Wartung + (``models/kind.py`` — Nutzer-Entscheidung: FB+ rechnet NICHT im + Lohnsteuerkern, Arbeitnehmerveranlagung), Meldewesen-Stammdaten + (BKNR/Lohnsteuernummer, GKZ am Partner, VSNR-Formatprüfung, eSV- + Abmeldegrund-Mapping), Belege Lohnzettel AT/Monatsdienstliste/ + Monatsjournal als QWeb (``report/belege_reports.xml``); - folgen nach Freigabe der Bgld.-APs: Meldewesen (L16/FinanzOnline, eSV/ELDA, AP5), Belege (AP6). @@ -48,6 +68,8 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft 'data/rule_parameters_sv_2026.xml', 'data/tasy_mapping.xml', 'views/tasy_views.xml', + 'views/meldewesen_views.xml', + 'report/belege_reports.xml', ], 'countries': ['at'], 'license': 'LGPL-3', diff --git a/addons/l10n_at_hr_payroll/models/__init__.py b/addons/l10n_at_hr_payroll/models/__init__.py index 97bafe9..41bb2b6 100644 --- a/addons/l10n_at_hr_payroll/models/__init__.py +++ b/addons/l10n_at_hr_payroll/models/__init__.py @@ -2,4 +2,7 @@ from . import tasy from . import lohnsteuer from . import sozialversicherung -from . import sozialversicherung \ No newline at end of file +from . import elda +from . import meldewesen +from . import kind +from . import lohnzettel_l16 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/elda.py b/addons/l10n_at_hr_payroll/models/elda.py new file mode 100644 index 0000000..c05bb65 --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/elda.py @@ -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) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/kind.py b/addons/l10n_at_hr_payroll/models/kind.py new file mode 100644 index 0000000..81d688a --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/kind.py @@ -0,0 +1,104 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Kind-Datenmodell (GP7): Kinder je Mitarbeiter für die Wartung des +Familienbonus Plus und die Befüllung des L16-Kinderblocks +(Lohnzettel-Finanz-Mitteilungssatz, DM-ORG 42.7.0 E.14, Felder +Nr. 196–209: je Kind 123 Stellen — KFAM/KVON/KSTAAT/KSTWE/KVSNR/ +KGEBD/KAFBZ/KAPFB/KAUHZ/KBGFP/KEGFP/KBHFP/KEHFP). + +Nutzer-Entscheidung 2026-09-12: die FB+-Steuerwirkung selbst ist KEIN +Bestandteil der Personalverrechnung (bei Unterschreiten der Steuer auf 0 +erfolgt die Geltendmachung über die Arbeitnehmerveranlagung) — das Modell +dient der Stammdaten-Wartung (Anspruch, Monate, halber Bonus) und der +L16-Meldung. Monatsfelder im Format MM (Kinderblock 2n); die +Betragsermittlung (FABO) rechnet der L16-Generator aus den gepflegten +Monaten und der Altersstaffel (§ 33 Abs 5 EStG: bis 18. Geburtstag +166,68 €/Monat, darüber 58,34 €/Monat solange Familienbeihilfe).""" +from odoo import fields, models + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + + l10n_at_payroll_kind_ids = fields.One2many( + 'l10n.at.payroll.kind', 'employee_id', + string='Kinder (Familienbonus Plus)', + groups="hr_payroll.group_hr_payroll_user") + + +class L10nAtPayrollKind(models.Model): + _name = 'l10n.at.payroll.kind' + _description = 'Kind (Familienbonus Plus / Lohnzettel L16)' + _order = 'geburtstag, id' + + employee_id = fields.Many2one( + 'hr.employee', string='Mitarbeiter:in', required=True, + index=True, ondelete='cascade') + familienname = fields.Char( + string='Familienname (KFAM)', required=True) + vorname = fields.Char(string='Vorname (KVON)') + geburtstag = fields.Date( + string='Geburtsdatum (KGEBD)', required=True, + help='Maßgeblich für die FB+-Staffel (bis zum 18. Geburtstag ' + '166,68 €/Monat, danach 58,34 €/Monat solange ' + 'Familienbeihilfe) und die Altersfilter (z. B. ' + 'Schwerarbeitsmeldung 40 M/35 F).') + versicherungsnummer = fields.Char( + string='Versicherungsnummer (KVSNR)', + help='10-stellig, LLLPTTMMJJ; nur wenn zugeteilt.') + staatsangehoerigkeit = fields.Char( + string='Staatsangehörigkeit (KSTAAT, 3-stelliger Code)') + staatsangehoerigkeit_2 = fields.Char( + string='2. Staatsangehörigkeit (KSTWE, 1-stelliger Code)') + # ------------------------------------------------------------------ + # Familienbonus Plus — Wartung (L16-Kinderblock) + # ------------------------------------------------------------------ + fab_plus = fields.Boolean( + string='Familienbonus Plus geltend gemacht', + help='L16 KFABO (Anzahl) und FABO (Betrag) ergeben sich aus den ' + 'Kindern mit gesetztem FB+-Kennzeichen.') + fab_monat_von = fields.Integer( + string='FB+ ab Monat (KBGFP)', + help='Monat (1–12), ab dem der volle Familienbonus Plus für ' + 'dieses Kind geltend gemacht wurde (L16 KBGFP).') + fab_monat_bis = fields.Integer( + string='FB+ bis Monat (KEGFP)', + help='Letzter Monat (1–12) des vollen Familienbonus Plus (L16 ' + 'KEGFP).') + fab_halb_monat_von = fields.Integer( + string='Halber FB+ ab Monat (KBHFP)', + help='Monat (1–12), ab dem nur mehr der halbe Familienbonus Plus ' + 'geltend gemacht wurde (Aufteilung zwischen den ' + 'Elternteilen; L16 KBHFP).') + fab_halb_monat_bis = fields.Integer( + string='Halber FB+ bis Monat (KEHFP)', + help='Letzter Monat (1–12) des halben Familienbonus Plus (L16 ' + 'KEHFP).') + + def _l10n_at_fab_monate(self, jahr): + """[(Monat, Anteil 1.0/0.5), …] des FB+ im Lohnzetteljahr aus den + gepflegten Monatsfeldern (voll + halb, Überschneidung: der + spätere Eintrag gewinnt).""" + self.ensure_one() + monate = {} + von, bis = self.fab_monat_von or 0, self.fab_monat_bis or 0 + if von and bis and 1 <= von <= bis <= 12: + for monat in range(von, bis + 1): + monate[monat] = 1.0 + hvon, hbis = (self.fab_halb_monat_von or 0, + self.fab_halb_monat_bis or 0) + if hvon and hbis and 1 <= hvon <= hbis <= 12: + for monat in range(hvon, hbis + 1): + monate[monat] = 0.5 + return sorted(monate.items()) + + def _l10n_at_fab_betrag(self, jahr, voll=166.68, vermindert=58.34): + """FB+-Jahresbetrag je Kind aus Monaten × Monatsrate; die Rate + wechselt mit dem 18. Geburtstag (monatliche Staffel: Monate vor + dem Geburtsmonat + 18 Jahre voller Satz, danach verminderter + Satz — § 33 Abs 5 EStG, solange Familienbeihilfe).""" + self.ensure_one() + if not self.geburtstag or not self.fab_plus: + return 0.0 + umschalt = self.geburtstag.month + return sum(anteil * (vermindert if monat >= umschalt else voll) + for monat, anteil in self._l10n_at_fab_monate(jahr)) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py b/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py new file mode 100644 index 0000000..c074d61 --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py @@ -0,0 +1,774 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Lohnzettel Finanz (L16) und Arbeitsstättenmeldung (GP7/AP5) — +Jahresdeklaration nach DM-ORG 42.7.0 (lokal `.firecrawl/ris/elda/`). + +- **L16** (BEST `LF`, zuständiger Träger VSTR `94` Bundesrechenzentrum): + Informationssatz I1 (SART `I1`, Länge 1100, Strukturversion 03) + + Mitteilungssatz L1 (SART `L1`, Länge 3500, Lohnzettelversion 28 — + zwingend ab 01.03.2026). KZ-Struktur nach lb-lvr-04 (✅ + RECHTSQUELLEN-Privat Abschn. 10): **KZ 245 = 210 − 215 − 220 − 230 − + 243**; die KZ-Werte sind aus den Kern-Flags (`l10n_at_bemessung`, + `steuerbefreiung_68`, `s67_abs`) und Regelcodes ableitbar — bewusst + KEIN paralleles KZ-Klassifikationsfeld (Widerspruchsrisiko zur + GP3-Steuerlogik). +- **Arbeitsstättenmeldung** § 34 Abs 6 ASVG (BEST `AD`, VSTR `ST` + Statistik Austria): Satzart 45 (Länge 300, Version 02) je AN mit der + Adresse der Arbeitsstätte am 31. 12. — läuft im selben Jahreslauf + (Nutzer-Entscheidung 5.1; Frist gemeinsam Ende Februar). +- Familienbonus Plus: **keine** Personalverrechnungs-Komponente + (Unterschreiten der Steuer auf 0 → Arbeitnehmerveranlagung; + Nutzer-Entscheidung 2026-09-12); die FB+-Felder des L1 + (BFABO/KFABO/VFABO/FABO + Kinderblock) werden aus der + Stammdaten-Wartung am Kind-Modell (`l10n.at.payroll.kind`) befüllt. +- Dateibasiert (Prämisse P7): Generierung + formale Prüfung als + Prüfprotokoll; Übertragung/ELDA-Konto beim Mandanten. + +Satzstrukturen: PDF-verifizierte Extraktion 2026-09-12 (DM-ORG 42.7.0, +Kapitel E.13/E.14/E.20; Positions-Ketten lückenlos geprüft).""" +from datetime import date, datetime + +import base64 +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from .elda import ( + L10N_AT_ELDA_IDTEIL, + L10nAtEldaBestand, + L10nAtEldaDatei, + L10nAtEldaSatz, +) + +_logger = logging.getLogger(__name__) + + +def l10n_at_namen(employee): + """(Familienname, Vorname) aus dem Odoo-Gesamtnamen: Split am + letzten Leerzeichen (österreichische Schreibweise „Vorname + Nachname“; mehrgliedrige Familiennamen werden als ein Feld + geführt — eSV FANA/VONA-Verifikation im Parallellauf).""" + name = (employee.name or '').strip() + if ' ' in name: + vorname, familienname = name.rsplit(' ', 1) + return familienname, vorname + return name, '' + +# ------------------------------------------------------------------ +# Informationssatz I1 (E.13, S. 219–220; Länge 1100) — Datenteil +# ------------------------------------------------------------------ +L16_INFORMATIONSSATZ = [ + (2, 'FSART', 21, 1, 'a'), # I + (3, 'CLEAR', 22, 6, 'a'), # Clearingstelle (RADAUS/OESTAT) + (4, 'CLDVR', 28, 7, 'a'), + (6, 'STNRL', 35, 9, 'n'), # Steuernummer Lohnverrechnungsstelle + (7, 'DVRNL', 44, 7, 'n'), + (9, 'STNRA', 51, 9, 'n'), # Steuernummer Arbeitgeber + (10, 'DVRNA', 60, 7, 'n'), + (11, 'ARTD', 67, 2, 'a'), # LZ = Lohnzetteldaten + (12, 'DTUE', 69, 8, 'n'), # JJJJMMTT + (13, 'ZTUE', 77, 6, 'n'), # HHMMSS + (14, 'STVE', 83, 2, 'n'), # Strukturversion 03 + (15, 'ANAM', 85, 37, 'a'), # AG-Name + (16, 'ATIT', 122, 20, 'a'), + (17, 'AADR', 142, 37, 'a'), + (18, 'ALKZ', 179, 3, 'a'), + (19, 'APLZ', 182, 10, 'a'), + (20, 'AORT', 192, 30, 'a'), + (21, 'INTE', 222, 11, 'a'), + (22, 'JAHR', 233, 4, 'n'), # Mitteilungszeitraum JJJJ + (23, 'GESA', 237, 7, 'n'), # Gesamtanzahl Mitteilungen im Jahr + (24, 'ANZA', 244, 7, 'n'), # Anzahl der aktuellen Übermittlung + (25, 'ATEL', 251, 16, 'a'), + (26, 'AFAX', 267, 16, 'a'), + (27, 'RESE', 283, 818, 'a'), +] + +# ------------------------------------------------------------------ +# Mitteilungssatz L1 (E.14, S. 223–233; Länge 3500) — Datenteil inkl. +# RESE-Grundstellungsfeldern (n-Felder Grundstellung 0!) +# ------------------------------------------------------------------ +L16_MITTEILUNGSSATZ = [ + (2, 'FSART', 21, 1, 'a'), # L + (3, 'CLADR', 22, 6, 'a'), + (4, 'CLDVR', 28, 7, 'a'), + (6, 'STNRL', 35, 9, 'n'), + (7, 'DVRNL', 44, 7, 'n'), + (8, 'ARTU', 51, 1, 'a'), # Art der Übermittlung (Prüfkatalog) + (9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1 + (10, 'ZTUE', 60, 6, 'n'), # wie I1 + (11, 'REFN', 66, 30, 'a'), # je AG+Jahr+DV eindeutig + (12, 'ARTL', 96, 2, 'n'), # Art des Lohnzettels (D.31) + (13, 'FEHL', 98, 20, 'a'), # Clearingstelle + (14, 'STAT', 118, 1, 'a'), # Clearingstelle + (15, 'FIND', 119, 1, 'a'), # 'J' = vom Hersteller für richtig + (16, 'BELZ', 120, 4, 'n'), # Beginn LZP TTMM + (17, 'ENLZ', 124, 4, 'n'), # Ende LZP TTMM + (18, 'JALZ', 128, 4, 'n'), # Jahr LZP + (19, 'SOZS', 132, 1, 'n'), # soziale Stellung (D.33) + (20, 'AVLN', 133, 4, 'n'), # VSNR LLLP + (21, 'AGBD', 137, 6, 'n'), # TTMMJJ + (22, 'ANAM', 143, 37, 'a'), + (23, 'ATIT', 180, 20, 'a'), + (24, 'AADR', 200, 37, 'a'), + (25, 'ALKZ', 237, 3, 'a'), + (26, 'APLZ', 240, 10, 'a'), + (27, 'AORT', 250, 30, 'a'), + (28, 'GESW', 280, 1, 'a'), + (29, 'GESM', 281, 1, 'a'), + (30, 'GESD', 282, 1, 'a'), + (31, 'VOLL', 283, 1, 'a'), # 'J' überwiegend Vollzeit + (32, 'TEIL', 284, 1, 'a'), # 'J' überwiegend Teilzeit + (33, 'AVAB', 285, 1, 'a'), # Alleinerzieherabsetzbetrag + (34, 'PVLN', 286, 4, 'n'), # Partner-VSNR LLLP (AVB) + (35, 'PGBD', 290, 6, 'n'), # TTMMJJ (AVB) + (36, 'AEAB', 296, 1, 'a'), # Alleinverdienerabsetzbetrag + (37, 'V210', 297, 1, 'a'), + (38, 'B210', 298, 10, 'n'), # KZ 210 § 25 (Cent) + (39, 'V215', 308, 1, 'a'), + (40, 'B215', 309, 10, 'n'), # KZ 215 § 68-steuerfrei + (41, 'V220', 319, 1, 'a'), + (42, 'B220', 320, 10, 'n'), # KZ 220 § 67 Abs 1/2/5 (inkl. 620-€) + (43, 'VIEB', 330, 1, 'a'), + (44, 'BIEB', 331, 10, 'n'), # insgesamt einbehaltene Beiträge + (45, 'V225', 341, 1, 'a'), + (46, 'B225', 342, 10, 'n'), # KZ 225 SV zu KZ 220 + (47, 'V226', 352, 1, 'a'), + (48, 'B226', 353, 10, 'n'), # KZ 226 SV zu § 67 Abs 3–8 + (49, 'V230', 363, 1, 'a'), + (50, 'B230', 364, 10, 'n'), # KZ 230 laufende SV/AK/WF + (51, 'RESE51', 374, 1, 'a'), + (52, 'RESE52', 375, 10, 'n'), + (53, 'VAUS', 385, 1, 'a'), + (54, 'BAUS', 386, 10, 'n'), + (55, 'VPEN', 396, 1, 'a'), + (56, 'BPEN', 397, 10, 'n'), + (57, 'VEFB', 407, 1, 'a'), + (58, 'BEFB', 408, 10, 'n'), + (59, 'VSTF', 418, 1, 'a'), + (60, 'BSTF', 419, 10, 'n'), + (61, 'VSSB', 429, 1, 'a'), + (62, 'BSSB', 430, 10, 'n'), + (63, 'V243', 440, 1, 'a'), + (64, 'B243', 441, 10, 'n'), # KZ 243 sonstige steuerfreie + (65, 'V245', 451, 1, 'a'), + (66, 'B245', 452, 10, 'n'), # KZ 245 steuerpflichtige Bezüge + (67, 'VIEL', 462, 1, 'a'), + (68, 'BIEL', 463, 10, 'n'), # insgesamt einbehaltene Lohnsteuer + (69, 'VABL', 473, 1, 'a'), + (70, 'BABL', 474, 10, 'n'), # abzügliche Lohnsteuer (Fixsätze) + (71, 'V260', 484, 1, 'a'), + (72, 'B260', 485, 10, 'n'), # KZ 260 anrechenbare Lohnsteuer + (73, 'VSOB', 495, 1, 'a'), + (74, 'BSOB', 496, 10, 'n'), + (75, 'VFB1', 506, 1, 'a'), + (76, 'BFB1', 507, 10, 'n'), + (77, 'RESE77', 517, 10, 'a'), + (78, 'VFB2', 527, 1, 'a'), + (79, 'BFB2', 528, 10, 'n'), + (80, 'VAUF', 538, 1, 'a'), + (81, 'BAUF', 539, 10, 'n'), + (82, 'VFB3', 549, 1, 'a'), + (83, 'BFB3', 550, 10, 'n'), + (84, 'VNEB', 560, 1, 'a'), + (85, 'BNEB', 561, 10, 'n'), + (86, 'PVON', 571, 2, 'n'), + (87, 'PBIS', 573, 2, 'n'), + (88, 'VPFG', 575, 1, 'a'), + (89, 'BPFG', 576, 10, 'n'), + (90, 'VSFB', 586, 1, 'a'), + (91, 'BSFB', 587, 10, 'n'), + (92, 'RESE92', 597, 1, 'a'), + (93, 'RESE93', 598, 10, 'n'), + (94, 'RESE94', 608, 1, 'a'), + (95, 'RESE95', 609, 10, 'n'), + (96, 'RESE96', 619, 1, 'a'), + (97, 'RESE97', 620, 10, 'n'), + (98, 'RESE98', 630, 1, 'a'), + (99, 'RESE99', 631, 8, 'n'), + (101, 'STNRA', 639, 9, 'n'), # auszahlende Stelle (Pensionen) + (102, 'DVRNA', 648, 7, 'n'), + (103, 'ANME', 655, 1, 'a'), # Unterbrechung LZZR + (104, 'KORR', 656, 1, 'a'), # 'K' berichtigter Lohnzettel + (105, 'VUEB', 657, 1, 'a'), + (106, 'BUEB', 658, 10, 'n'), + (107, 'INTE', 668, 11, 'a'), + (108, 'WOBD', 679, 12, 'a'), # Ordnungsbegriff DG (Personalnummer) + (109, 'ZOBD', 691, 15, 'a'), + (110, 'ANZK', 706, 2, 'n'), # Anzahl Kinder § 106 + (111, 'RESE111', 708, 1, 'a'), + (112, 'RESE112', 709, 1, 'a'), + (113, 'RESE113', 710, 1, 'a'), + (114, 'RESE114', 711, 1, 'a'), + (115, 'RESE115', 712, 1, 'a'), + (116, 'RESE116', 713, 1, 'a'), + (117, 'RESE117', 714, 1, 'a'), + (118, 'RESE118', 715, 1, 'a'), + (119, 'RESE119', 716, 1, 'a'), + (120, 'RESE120', 717, 1, 'a'), + (121, 'RESE121', 718, 1, 'a'), + (122, 'RESE122', 719, 1, 'a'), + (123, 'VAPK', 720, 1, 'a'), + (124, 'BAPK', 721, 10, 'n'), + (125, 'EPAB', 731, 1, 'a'), + (126, 'VENT', 732, 1, 'a'), + (127, 'ENTW', 733, 10, 'n'), + (128, 'VPRE', 743, 1, 'a'), + (129, 'PREI', 744, 10, 'n'), + (130, 'WERK', 754, 2, 'n'), + (131, 'VPEND', 756, 1, 'a'), + (132, 'BPEND', 757, 10, 'n'), + (134, 'UEBMO', 767, 2, 'n'), + (135, 'RESE135', 769, 1, 'a'), + (136, 'RESE136', 770, 10, 'n'), + (137, 'LDKZA', 780, 3, 'a'), + (138, 'VWBKB', 783, 1, 'a'), + (139, 'WBKB', 784, 10, 'n'), + (140, 'RESE140', 794, 1, 'a'), + (141, 'RESE141', 795, 10, 'a'), + (142, 'ERVAB', 805, 1, 'a'), + (143, 'BFABO', 806, 1, 'a'), # Familienbonus Plus 'J' + (144, 'KFABO', 807, 2, 'n'), # Anzahl Kinder FB+ + (145, 'VFABO', 809, 1, 'a'), + (146, 'FABO', 810, 10, 'n'), # FB+-Betrag + (147, 'HOTA', 820, 3, 'n'), # Telearbeitstage + (148, 'VHOPA', 823, 1, 'a'), + (149, 'HOPA', 824, 10, 'n'), # Homeoffice-Pauschale § 26 Z 9 lit a + (150, 'VKOUN', 834, 1, 'a'), + (151, 'KOUN', 835, 10, 'n'), + (152, 'VMAGB', 845, 1, 'a'), + (153, 'MAGB', 846, 10, 'n'), + (154, 'FLABZ', 856, 1, 'a'), # freiwilliger Lohnsteuerabzug § 47 + (155, 'AUEZG', 857, 1, 'a'), + (156, 'VTEPR', 858, 1, 'a'), + (157, 'TEPR', 859, 10, 'n'), + (158, 'VMAPR', 869, 1, 'a'), + (159, 'MAPR', 870, 10, 'n'), + (160, 'STUM', 880, 4, 'n'), + (161, 'STUMJ', 884, 4, 'n'), + (162, 'ZF673', 888, 1, 'a'), + (163, 'BDOZ', 889, 1, 'a'), + (164, 'VFSVB', 890, 1, 'a'), + (165, 'FSVB', 891, 10, 'n'), + (166, 'PLST', 901, 1, 'a'), + (167, 'VKAKL', 902, 1, 'a'), + (168, 'KAKL', 903, 10, 'n'), + (169, 'VZUKB', 913, 1, 'a'), + (170, 'ZUKB', 914, 10, 'n'), + (171, 'GEBD', 924, 8, 'n'), + (172, 'GEBP', 932, 8, 'n'), + (173, 'AKZKB', 940, 2, 'n'), + (174, 'RESE174', 942, 59, 'a'), + (175, 'REFN1', 1001, 30, 'a'), + (176, 'RESE176', 1031, 19, 'a'), + (177, 'BSBKFZ', 1050, 10, 'n'), + (178, 'BSBWR', 1060, 10, 'n'), + (179, 'BSBSB', 1070, 10, 'n'), + (180, 'SB681', 1080, 10, 'n'), + (181, 'SB682', 1090, 10, 'n'), + (182, 'BZUKG', 1100, 10, 'n'), + (183, 'MAKBG', 1110, 10, 'n'), + (184, 'MABST', 1120, 10, 'n'), + (185, 'ZUCSG', 1130, 10, 'n'), + (186, 'GUTSC', 1140, 10, 'n'), + (187, 'MARAB', 1150, 10, 'n'), + (188, 'SBK00', 1160, 1, 'a'), + (189, 'SBK15', 1161, 1, 'a'), + (190, 'SBK20', 1162, 1, 'a'), + (191, 'SBKDW', 1163, 1, 'a'), + (192, 'AKKFZ', 1164, 10, 'n'), + (193, 'KEAKFZ', 1174, 10, 'n'), + (194, 'AGLAG', 1184, 10, 'n'), + (195, 'RESE195', 1194, 307, 'a'), + (210, 'RESE210', 3346, 155, 'a'), +] + +# Kinderblock (E.14, Felder 196–209): je Kind 123 Stellen ab Pos. 1501 +# (Block 1 = 1501–1623 … Block 15 = 3223–3345; Positionen aus den +# Feldlängen arithmetisch abgeleitet, PDF-verifiziert). +_KINDER_FELDER = [ + (196, 'KFAM', 0, 30, 'a'), # Familienname + (197, 'KVON', 30, 30, 'a'), # Vorname + (198, 'KSTAAT', 60, 3, 'a'), # Staatsangehörigkeit + (199, 'KSTWE', 63, 1, 'a'), # 2. Staatsangehörigkeit + (200, 'KVSNR', 64, 10, 'n'), # VSNR des Kindes + (201, 'KGEBD', 74, 8, 'n'), # TTMMJJJJ + (202, 'KAFBZ', 82, 1, 'a'), # Kennzeichen FB+ + (203, 'KAPFB', 83, 1, 'a'), + (204, 'KAUHZ', 84, 1, 'a'), + (205, 'KBGFP', 85, 2, 'n'), # FB+ ab Monat + (206, 'KEGFP', 87, 2, 'n'), # FB+ bis Monat + (207, 'KBHFP', 89, 2, 'n'), # halber FB+ ab Monat + (208, 'KEHFP', 91, 2, 'n'), # halber FB+ bis Monat + (209, 'RESEK', 93, 30, 'a'), +] + + +def _kinderblock_struktur(): + felder = [] + for kind_nr in range(1, 16): + basis = 1501 + (kind_nr - 1) * 123 + for nr, name, offset, laenge, typ in _KINDER_FELDER: + felder.append( + (nr, '%s_%d' % (name, kind_nr), basis + offset, + laenge, typ)) + return felder + + +L16_MITTEILUNGSSATZ += _kinderblock_struktur() + +# ------------------------------------------------------------------ +# Adresse der Arbeitsstätte § 34 Abs 6 ASVG (E.20; SART 45, Länge 300, +# Version 02; BEST AD, VSTR ST) +# ------------------------------------------------------------------ +ARBEITSSTAETTE_SATZ = [ + (2, 'KVTR', 21, 2, 'a'), # KV-Träger (aus BKNR-Präfix) + (3, 'BKNR', 23, 10, 'a'), + (4, 'VSNR', 33, 10, 'n'), + (5, 'BZVM', 43, 2, 'n'), # Beitragszeit von MM + (6, 'BZBM', 45, 2, 'n'), # Beitragszeit bis MM + (7, 'BZJJ', 47, 4, 'n'), # Beitragsjahr + (8, 'FAMS', 51, 2, 'a'), # Finanzamtsnummer meldende Stelle + (9, 'STNR', 53, 9, 'n'), # Steuernummer DG + (10, 'ASTR', 62, 50, 'a'), # Straße der Arbeitsstätte + (11, 'ANRV', 112, 5, 'a'), # Hausnummer von + (12, 'ANRB', 117, 5, 'a'), # Hausnummer bis + (13, 'ASTG', 122, 5, 'a'), # Stiege + (14, 'ATOP', 127, 5, 'a'), # Tür/Top + (15, 'APLZ', 132, 9, 'a'), # PLZ der Arbeitsstätte + (16, 'AORT', 141, 40, 'a'), # Ort + (17, 'AGKZ', 181, 5, 'n'), # Gemeindekennziffer + (18, 'APOL', 186, 50, 'a'), # Politische Gemeinde/Staat + (19, 'AUDT', 236, 8, 'n'), # TTMMJJJJ + (20, 'WOBD', 244, 12, 'a'), + (21, 'ZOBD', 256, 15, 'a'), + (22, 'REFN', 271, 30, 'a'), +] + + +class L10nAtPayrollL16Line(models.Model): + _name = 'l10n.at.payroll.l16.line' + _description = 'Lohnzettel Finanz — Mitteilungssatz-Daten je AN' + _order = 'employee_name' + + l16_id = fields.Many2one( + 'l10n.at.payroll.l16', required=True, ondelete='cascade', + index=True) + employee_id = fields.Many2one('hr.employee', required=True) + employee_name = fields.Char(related='employee_id.name', + readonly=True, store=True) + versicherungsnummer = fields.Char() + soziale_stellung = fields.Char(help='0–8 (D.33)') + belz = fields.Char(string='LZP ab (TTMM)') + enlz = fields.Char(string='LZP bis (TTMM)') + kz210 = fields.Monetary(currency_field='currency_id') + kz215 = fields.Monetary(currency_field='currency_id') + kz220 = fields.Monetary(currency_field='currency_id') + kz225 = fields.Monetary(currency_field='currency_id') + kz226 = fields.Monetary(currency_field='currency_id') + kz230 = fields.Monetary(currency_field='currency_id') + kz243 = fields.Monetary(currency_field='currency_id') + kz245 = fields.Monetary(currency_field='currency_id') + lohnsteuer = fields.Monetary(currency_field='currency_id', + string='KZ 260 anrechenbare LSt') + beitraege = fields.Monetary(currency_field='currency_id', + string='Einbehaltene Beiträge (VIEB)') + fab_betrag = fields.Monetary( + currency_field='currency_id', string='Familienbonus Plus') + fab_kinder = fields.Integer(string='Anzahl Kinder FB+') + refn = fields.Char() + currency_id = fields.Many2one( + 'res.currency', related='l16_id.company_id.currency_id') + + def _kz_salden(self, slips): + """KZ-Summen je AN aus validierten Läufen. **KZ 210 = Brutto- + bezüge gem. § 25 gesamt** (laufende UND sonstige §-67-Bezüge, + ohne § 26/§ 3 Abs 1 Z 16b — DM-ORG E.14 Feldbeschreibung), denn + KZ 245 = 210 − 215 − 220 − 230 − 243 ist die laufende Steuer- + bemessung: Gesamtbrutto − §-68-Freie − sonstige Bezüge − + laufende SV-DN. 215 aus `_l10n_at_freibetraege_68` des + GP3-Kerns (Monats-Caps 400/600 und ÜStd 15/170 eingerechnet); + 220 = sonstige § 67 Abs 1/2/5 ohne Abs-3/6; 225 = SV-DN auf + KZ 220; 230 = laufende SV-DN + AK + WF; VIEB = 230 + 225 + + 226; 260/BIEL = LST_LFD + LST_SZ.""" + self.ensure_one() + salden = dict.fromkeys( + ('kz210', 'kz215', 'kz220', 'kz225', 'kz226', 'kz230', + 'kz243', 'kz245', 'lohnsteuer', 'beitraege'), 0.0) + for slip in slips: + for line in slip.line_ids: + rule = line.salary_rule_id + if not line.total: + continue + if rule.l10n_at_bemessung == 'laufend': + salden['kz210'] += line.total + elif (rule.l10n_at_bemessung == 'sonstig' + and rule.l10n_at_s67_abs == 'keine'): + salden['kz210'] += line.total + salden['kz220'] += line.total + elif line.code == 'SVDN_SZ': + salden['kz225'] += line.total + elif line.code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', + 'SVDN_AK', 'SVDN_WF'): + salden['kz230'] += line.total + elif line.code in ('LST_LFD', 'LST_SZ'): + salden['lohnsteuer'] += line.total + freibe = slip._l10n_at_freibetraege_68() + salden['kz215'] += freibe['szfn'] + freibe['ue_std'] + salden['beitraege'] = salden['kz230'] + salden['kz225'] \ + + salden['kz226'] + salden['kz245'] = (salden['kz210'] - salden['kz215'] + - salden['kz220'] - salden['kz230'] + - salden['kz243']) + return salden + + def _soziale_stellung(self, employee): + """D.33: 1 Lehrling, 2 Arbeiter, 3 Angestellte (0 sonstige) — + MVP: Stand des aktuellen Vertrags.""" + version = employee.version_id + if version and getattr(version, 'l10n_at_lehrling', False): + return '1' + if version and getattr( + version, 'l10n_at_personalgruppe', None) == 'arbeiter': + return '2' + return '3' + + +class L10nAtPayrollL16(models.Model): + _name = 'l10n.at.payroll.l16' + _description = 'Lohnzettel Finanz (L16) & Arbeitsstättenmeldung' + _inherit = ['mail.thread'] + _order = 'jahr desc, id desc' + + company_id = fields.Many2one( + 'res.company', required=True, + default=lambda self: self.env.company) + jahr = fields.Integer(required=True, string='Lohnzahlungszeitraum') + art = fields.Selection( + [('01', 'Lohnzettel (Jahresbeleg)'), + ('18', 'Berichtigter Lohnzettel (binnen 2 Wochen)')], + required=True, default='01', tracking=True) + line_ids = fields.One2many( + 'l10n.at.payroll.l16.line', 'l16_id', string='Mitteilungssätze') + datei_l16 = fields.Binary( + attachment=True, string='L16-Datei', copy=False) + datei_l16_name = fields.Char(copy=False) + datei_arbeitsstaette = fields.Binary( + attachment=True, string='Datei Arbeitsstättenmeldung', + copy=False) + datei_arbeitsstaette_name = fields.Char(copy=False) + protokoll = fields.Text( + string='Prüfprotokoll', + help='Formale Prüfung (Satzlängen, KZ-245-Formel, VSNR-Format, ' + 'Pflicht-Stammdaten) und Annahmen des Laufes; die ' + 'Fachprüfung bleibt der Clearingstelle (Mitteilungsfile, ' + 'GP8-Rest).') + state = fields.Selection( + [('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft', + required=True, tracking=True) + + _sql_constraints = [( + 'l16_company_jahr_art_uniq', 'unique(company_id, jahr, art)', + 'Für dieses Jahr existiert bereits eine L16-Deklaration dieser ' + 'Art.')] + + def _l10n_at_slips(self): + self.ensure_one() + return self.env['hr.payslip'].search([ + ('company_id', '=', self.company_id.id), + ('date_from', '>=', date(self.jahr, 1, 1)), + ('date_from', '<=', date(self.jahr, 12, 31)), + ('state', 'in', ('validated', 'paid')), + ]) + + def _l10n_at_hersteller(self): + partner = self.company_id.partner_id + return {'name': 'odoo-at-payroll', 'kfz': 'A', + 'plz': partner.zip, 'ort': partner.city, + 'strasse': partner.street} + + @api.model + def _vorzeichen(self, betrag): + """V-Feld: blank bei 0, sonst '+'/'-' (L1-Feldregeln).""" + if not betrag: + return None + return '+' if betrag > 0 else '-' + + def _l10n_at_l1_satz(self, line, stnr, heute, jetzt): + self.ensure_one() + employee = line.employee_id + version = employee.version_id + satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + L16_MITTEILUNGSSATZ, 3500, + bezeichnung='L1 %s' % employee.name) + satz.setze('SART', 'L1') + satz.setze('VSTR', '94') + satz.setze('FSART', 'L') + satz.setze('STNRL', stnr) + satz.setze('STNRA', stnr) + satz.setze_datum('DTUE', heute, 'JJJJMMTT') + satz.setze_zeit('ZTUE', jetzt) + satz.setze('REFN', line.refn) + satz.setze('ARTL', self.art) + if self.art == '18': + satz.setze('KORR', 'K') + satz.setze('FIND', 'J') + satz.setze('BELZ', line.belz) + satz.setze('ENLZ', line.enlz) + satz.setze('JALZ', self.jahr) + satz.setze('SOZS', line.soziale_stellung) + satz.setze('AVLN', (line.versicherungsnummer or '')[:4]) + satz.setze_datum('AGBD', employee.birthday, 'TTMMJJ') + satz.setze('ANAM', (employee.name or '')[:37]) + satz.setze('AADR', (version.private_street or '')[:37] + if version else '') + satz.setze('ALKZ', (version.private_country_id.code + if version and version.private_country_id + else 'AUT')) + satz.setze('APLZ', version.private_zip if version else '') + satz.setze('AORT', (version.private_city or '')[:30] + if version else '') + if version and version.sex == 'female': + satz.setze('GESW', 'J') + elif version and version.sex == 'male': + satz.setze('GESM', 'J') + elif version and version.sex: + satz.setze('GESD', 'J') + vollzeit = bool(version and version.work_time_rate >= 1.0) + satz.setze('VOLL', 'J' if vollzeit else 'N') + satz.setze('TEIL', 'N' if vollzeit else 'J') + if version and version.l10n_at_familienabsetzbetrag == 'avab': + satz.setze('AVAB', 'J') + elif version \ + and version.l10n_at_familienabsetzbetrag == 'avb': + satz.setze('AEAB', 'J') + for betrag, vfeld, bfeld in ( + (line.kz210, 'V210', 'B210'), + (line.kz215, 'V215', 'B215'), + (line.kz220, 'V220', 'B220'), + (line.kz225, 'V225', 'B225'), + (line.kz226, 'V226', 'B226'), + (line.kz230, 'V230', 'B230'), + (line.kz243, 'V243', 'B243'), + (line.kz245, 'V245', 'B245'), + (line.beitraege, 'VIEB', 'BIEB'), + (line.lohnsteuer, 'VIEL', 'BIEL'), + (line.lohnsteuer, 'V260', 'B260')): + satz.setze(vfeld, self._vorzeichen(betrag)) + satz.setze(bfeld, abs(betrag) if betrag else 0, cent=True) + satz.setze('WOBD', 'EMP-%d' % employee.id) + if version and version.children: + satz.setze('ANZK', version.children) + # Familienbonus Plus aus der Kind-Stammdaten-Wartung (keine + # Personalverrechnungs-Komponente — Nutzer-Entscheidung). + if line.fab_kinder: + satz.setze('BFABO', 'J') + satz.setze('KFABO', line.fab_kinder) + satz.setze('VFABO', self._vorzeichen(line.fab_betrag)) + satz.setze('FABO', abs(line.fab_betrag) if line.fab_betrag + else 0, cent=True) + kinder = self.env['l10n.at.payroll.kind'].search([ + ('employee_id', '=', employee.id), + ('fab_plus', '=', True)]) + for nummer, kind in enumerate(kinder[:15], start=1): + satz.setze('KFAM_%d' % nummer, + (kind.familienname or '')[:30]) + satz.setze('KVON_%d' % nummer, (kind.vorname or '')[:30]) + satz.setze('KSTAAT_%d' % nummer, kind.staatsangehoerigkeit) + satz.setze('KSTWE_%d' % nummer, + kind.staatsangehoerigkeit_2) + satz.setze('KVSNR_%d' % nummer, kind.versicherungsnummer) + satz.setze_datum('KGEBD_%d' % nummer, kind.geburtstag, + 'TTMMJJJJ') + satz.setze('KAFBZ_%d' % nummer, 'J') + satz.setze('KBGFP_%d' % nummer, kind.fab_monat_von) + satz.setze('KEGFP_%d' % nummer, kind.fab_monat_bis) + satz.setze('KBHFP_%d' % nummer, kind.fab_halb_monat_von) + satz.setze('KEHFP_%d' % nummer, kind.fab_halb_monat_bis) + return satz + + def _l10n_at_l16_bestand(self, erstellungsdatum=None): + self.ensure_one() + heute = erstellungsdatum or date.today() + jetzt = datetime.now() + partner = self.company_id.partner_id + stnr = self.company_id.l10n_at_lohnsteuernummer + informationssatz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + L16_INFORMATIONSSATZ, 1100, + bezeichnung='L16-Informationssatz') + informationssatz.setze('SART', 'I1') + informationssatz.setze('VSTR', '94') + informationssatz.setze('FSART', 'I') + informationssatz.setze('CLEAR', 'RADAUS') + informationssatz.setze('STNRL', stnr) + informationssatz.setze('STNRA', stnr) + informationssatz.setze('ARTD', 'LZ') + informationssatz.setze_datum('DTUE', heute, 'JJJJMMTT') + informationssatz.setze_zeit('ZTUE', jetzt) + informationssatz.setze('STVE', '03') + informationssatz.setze('ANAM', (partner.name or '')[:37]) + informationssatz.setze('AADR', (partner.street or '')[:37]) + informationssatz.setze('ALKZ', 'AUT') + informationssatz.setze('APLZ', partner.zip) + informationssatz.setze('AORT', (partner.city or '')[:30]) + informationssatz.setze('JAHR', self.jahr) + informationssatz.setze('GESA', len(self.line_ids)) + informationssatz.setze('ANZA', len(self.line_ids)) + datensaetze = [informationssatz] + [ + self._l10n_at_l1_satz(line, stnr, heute, jetzt) + for line in self.line_ids] + return L10nAtEldaBestand( + 'LF', '94', '28', hersteller=self._l10n_at_hersteller(), + datensaetze=datensaetze) + + def _l10n_at_arbeitsstaette_bestand(self, erstellungsdatum=None): + """§ 34 Abs 6 ASVG je AN mit mBGM-Bezug: Adresse der + Arbeitsstätte am 31. 12. (Dienstort-Partner + GKZ).""" + self.ensure_one() + heute = erstellungsdatum or date.today() + bknr = self.company_id.l10n_at_beitragskontonummer + stnr = self.company_id.l10n_at_lohnsteuernummer + datensaetze = [] + warnungen = [] + for line in self.line_ids: + employee = line.employee_id + dienstort = employee.address_id + if not dienstort or not dienstort.l10n_at_gemeindekennziffer: + warnungen.append( + 'Arbeitsstätte %s: Dienstort-Partner bzw. ' + 'Gemeindekennziffer fehlt (res.partner.' + 'l10n_at_gemeindekennziffer am Dienstort-Partner ' + 'pflegen).' % employee.name) + satz = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + ARBEITSSTAETTE_SATZ, 300, + bezeichnung='Arbeitsstätte %s' % employee.name) + satz.setze('SART', '45') + satz.setze('VSTR', 'ST') + satz.setze('KVTR', (bknr or '')[:2]) + satz.setze('BKNR', bknr) + satz.setze('VSNR', line.versicherungsnummer) + satz.setze('BZVM', (line.belz or '01')[:2]) + satz.setze('BZBM', (line.enlz or '12')[:2]) + satz.setze('BZJJ', self.jahr) + satz.setze('FAMS', (stnr or '')[:2]) + satz.setze('STNR', stnr) + satz.setze('ASTR', (dienstort.street if dienstort else '') + [:50]) + satz.setze('APLZ', dienstort.zip if dienstort else '') + satz.setze('AORT', (dienstort.city if dienstort else '')[:40]) + satz.setze('AGKZ', + dienstort.l10n_at_gemeindekennziffer + if dienstort else None) + satz.setze('APOL', (dienstort.city if dienstort else '')[:50]) + satz.setze_datum('AUDT', heute, 'TTMMJJJJ') + satz.setze('WOBD', 'EMP-%d' % employee.id) + datensaetze.append(satz) + if not datensaetze: + return None, warnungen + return L10nAtEldaBestand( + 'AD', 'ST', '02', hersteller=self._l10n_at_hersteller(), + datensaetze=datensaetze), warnungen + + def action_erzeugen(self): + """Lauf: KZ-Daten je AN aus validierten Läufen sammeln, formale + Prüfung (KZ-245-Formel, Pflicht-Stammdaten), Dateien erzeugen. + Fehler brechen ab (UserError, DmfA-Muster listenweise); + Warnungen landen im Prüfprotokoll.""" + for deklaration in self: + fehler = [] + warnungen = [] + company = deklaration.company_id + if not company.l10n_at_lohnsteuernummer: + fehler.append('Lohnsteuernummer der Company fehlt ' + '(l10n_at_lohnsteuernummer, 9-stellig).') + if not company.l10n_at_beitragskontonummer: + fehler.append('Beitragskontonummer der Company fehlt ' + '(l10n_at_beitragskontonummer, BKNR).') + slips = deklaration._l10n_at_slips() + nach_mitarbeiter = {} + for slip in slips: + nach_mitarbeiter.setdefault(slip.employee_id, []) \ + .append(slip) + deklaration.line_ids.unlink() + zeilen_werte = [] + for employee, employee_slips in nach_mitarbeiter.items(): + version = employee.version_id + if not (version and version.ssnid): + fehler.append( + 'Mitarbeiter %s ohne Versicherungsnummer ' + '(hr.version.ssnid).' % employee.name) + continue + if not employee.birthday: + fehler.append( + 'Mitarbeiter %s ohne Geburtsdatum.' % employee.name) + continue + line = deklaration.env['l10n.at.payroll.l16.line'].new({ + 'employee_id': employee.id}) + salden = line._kz_salden(employee_slips) + erste = min(s.date_from for s in employee_slips) + letzte = max(s.date_to for s in employee_slips) + kinder = deklaration.env['l10n.at.payroll.kind'].search([ + ('employee_id', '=', employee.id), + ('fab_plus', '=', True)]) + zeilen_werte.append({ + 'employee_id': employee.id, + 'versicherungsnummer': version.ssnid, + 'soziale_stellung': + line._soziale_stellung(employee), + 'belz': erste.strftime('%d%m'), + 'enlz': letzte.strftime('%d%m'), + 'refn': 'L16-%d-%d-%d' % (company.id, + deklaration.jahr, + employee.id), + 'fab_kinder': len(kinder), + 'fab_betrag': sum( + kind._l10n_at_fab_betrag(deklaration.jahr) + for kind in kinder), + **salden, + }) + for werte in zeilen_werte: + deklaration.env['l10n.at.payroll.l16.line'].create( + dict(werte, l16_id=deklaration.id)) + for line in deklaration.line_ids: + formel = (line.kz210 - line.kz215 - line.kz220 + - line.kz230 - line.kz243) + if abs(formel - line.kz245) > 0.02: + fehler.append( + '%s: KZ-245-Formel verletzt (%.2f ≠ %.2f).' + % (line.employee_name, formel, line.kz245)) + if line.kz245 < 0: + warnungen.append( + '%s: KZ 245 negativ (Bezüge übersteigen die ' + 'Abzüge) — FinOnl-Konsistenzprüfung prüft.' + % line.employee_name) + if fehler: + deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % ( + '\n'.join(fehler), '\n'.join(warnungen) or '—') + raise UserError('L16-Lauf mit Fehlern abgebrochen:\n' + + '\n'.join(fehler)) + if deklaration.line_ids: + datei = L10nAtEldaDatei( + [deklaration._l10n_at_l16_bestand()]) + deklaration.write({ + 'datei_l16': base64.b64encode( + datei.render()), + 'datei_l16_name': 'l16_%d_%s.txt' % ( + deklaration.jahr, + deklaration.company_id.l10n_at_beitragskontonummer + or deklaration.company_id.id), + }) + bestand, ast_warn = \ + deklaration._l10n_at_arbeitsstaette_bestand() + warnungen.extend(ast_warn) + if bestand is not None: + datei = L10nAtEldaDatei([bestand]) + deklaration.write({ + 'datei_arbeitsstaette': base64.b64encode( + datei.render()), + 'datei_arbeitsstaette_name': + 'arbeitsstaette_%d.txt' % deklaration.jahr, + }) + deklaration.protokoll = 'WARNUNGEN:\n%s' % ( + '\n'.join(warnungen) or '—') + deklaration.state = 'done' + return True \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/meldewesen.py b/addons/l10n_at_hr_payroll/models/meldewesen.py new file mode 100644 index 0000000..91d209c --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/meldewesen.py @@ -0,0 +1,96 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Meldewesen-Stammdaten (GP7/AP5): ELDA-/FinOnl-Identifikation der +Dienstgeberin und der Arbeitsstätten. + +- ``res.company``: Beitragskontonummer (BKNR — ELDA-Feld, Präfix = + KV-Träger) und Lohnsteuernummer (9-stellig: 2 Stellen Finanzamt + 7 + Stellen Nummer; L16-I1 STNRA/STNRL, Arbeitsstättenmeldung STNR). +- ``res.partner``: österreichische Gemeindekennziffer (AGKZ) für die + Arbeitsstättenmeldung nach § 34 Abs 6 ASVG — Odoo hält keine GKZ, das + Feld ist mandantenseitig zu pflegen (Warnung des L16-Laufs, wenn es am + Dienstort-Partner fehlt). +- ``hr.version``: Kennzeichen fallweise Beschäftigung (§ 33 Abs 1a + Z 1 ASVG — tagesgenaue vereinfachte Anmeldung vor Arbeitsantritt, + DM-ORG BEST MA) und VSNR-Formatprüfung (10-stellig LLLPTTMMJJ; + Prüfzifferalgorithmus der SVS ist nicht beschafft → nur Format/ + Plausibilität, Rest dokumentiert). +- ``hr.departure.reason``: Mapping auf den eSV-Abmeldegrund (AGRD, + DM-ORG D.22; Standard ``00`` sonstiger Grund mit Ende des + Beschäftigungsverhältnisses).""" +from odoo import api, fields, models + +from odoo.exceptions import ValidationError + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_at_beitragskontonummer = fields.Char( + string='AT Beitragskontonummer (BKNR)', + help='eSV-Beitragskontonummer des Dienstgebers (10 Stellen;\n' + 'die ersten Stellen codieren den KV-Träger). Pflichtfeld\n' + 'jeder ELDA-Meldung (mBGM-Paket BKNR, Versichertenmeldung,\n' + 'Arbeitsstättenmeldung, fallweise Anmeldung).') + l10n_at_lohnsteuernummer = fields.Char( + string='AT Lohnsteuernummer', + help='9-stellig: 2 Stellen Finanzamtsnummer + 7 Stellen\n' + 'Steuernummer (Lohnzettel Finanz I1 STNRA/STNRL;\n' + 'Arbeitsstättenmeldung STNR).') + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + l10n_at_gemeindekennziffer = fields.Char( + string='AT Gemeindekennziffer (GKZ)', + help='5-stellige österreichische Gemeindekennzahl für die\n' + 'Arbeitsstättenmeldung nach § 34 Abs 6 ASVG (Feld AGKZ).\n' + 'Am Dienstort-Partner des Mitarbeiters pflegen.') + + +class HrVersion(models.Model): + _inherit = 'hr.version' + + l10n_at_fallweise = fields.Boolean( + string='AT Fallweise Beschäftigung', + groups="hr_payroll.group_hr_payroll_user", tracking=True, + help='Fallweise Beschäftigung (§ 33 Abs 1a Z 1 ASVG): je\n' + 'Beschäftigungstag ist die vereinfachte Anmeldung vor\n' + 'Arbeitsantritt zu übermitteln (ELDA BEST MA, Satzarten\n' + '32/33); An-/Abmeldung laufen über die mBGM (G3/G4).') + + @api.constrains('ssnid') + def _l10n_at_vsnr_format(self): + """VSNR-Formatprüfung LLLPTTMMJJ (10 Stellen, Ziffern, plausibles + Geburtsdatum TTMMJJ an den Stellen 5–10). Der + Prüfzifferalgorithmus der SVS ist nicht beschafft → nur + Format/Plausibilität (Rest dokumentiert; die Meldungsgeneratoren + listen ungültige VSNR listenweise als UserError).""" + for version in self: + if not version.ssnid: + continue + roh = version.ssnid.strip() + if len(roh) != 10 or not roh.isdigit(): + raise ValidationError( + 'Versicherungsnummer %r: 10 Ziffern im Format LLLPTTMMJJ ' + 'erwartet.' % version.ssnid) + try: + tag = int(roh[4:6]) + monat = int(roh[6:8]) + if not 1 <= tag <= 31 or not 1 <= monat <= 12: + raise ValueError() + except ValueError: + raise ValidationError( + 'Versicherungsnummer %r enthält kein plausibles ' + 'Geburtsdatum (TTMMJJ an den Stellen 5–10).' + % version.ssnid) + + +class HrDepartureReason(models.Model): + _inherit = 'hr.departure.reason' + + l10n_at_agrd = fields.Char( + string='AT eSV-Abmeldegrund (AGRD)', + help='2-stelliger Abmeldegrund-Code der Versichertenmeldung ' + '(DM-ORG D.22); Standard 00 „Sonstiger Grund mit Ende des ' + 'Beschäftigungsverhältnisses“.') \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/monatsreports.py b/addons/l10n_at_hr_payroll/models/monatsreports.py new file mode 100644 index 0000000..82bf43b --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/monatsreports.py @@ -0,0 +1,90 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Monatsdienstliste und Monatsjournal (GP7/AP6): QWeb-Datenlieferanten +auf dem Lohnlauf (hr.payslip.run) — voraberechnete Aggregation über +`hr.payslip.line` (read_group-Muster des Contribution Register), damit +die Templates reine Darstellung bleiben. + +- **Monatsdienstliste**: je AN eine Zeile mit den mBGM-Kontrollgrößen + (Versicherungstage/Beitragsgrundlagen je Beitragsmonat, DN- und + DG-Beiträge, DG-Anteile) — Abstimmung Abrechnung ↔ SV-Meldung. +- **Monatsjournal**: je Lohnart die Summe über den Lauf (Brutto, + Abzüge, DG-Anteile, Netto) mit Kontrollsummen gegen die + GP6-Buchung.""" +from odoo import models + + +class L10nAtPayrollMonatsReports(models.AbstractModel): + _name = 'report.l10n_at_hr_payroll.report_monatsdienstliste' + _description = 'Monatsdienstliste (GP7/AP6)' + + def _get_report_values(self, docids, data=None): + runs = self.env['hr.payslip.run'].browse(docids) + zeilen = [] + for run in runs: + for slip in run.slip_ids.filtered( + lambda p: p.state not in ('draft', 'cancel')): + version = slip.version_id + basen = slip._l10n_at_sv_basis_laufen_monate() + sv_dn = sum( + slip._l10n_at_svdn(k) for k in + ('kv', 'pv', 'av', 'ak', 'wf')) + sv_dg = slip._l10n_at_svdg_gesamt() + sz_dn = slip._l10n_at_svdn_sz() + sz_basis = slip._l10n_at_svdn_basis_sz() + zeilen.append({ + 'slip': slip, + 'versicherungsnummer': version.ssnid if version else '', + 'personalgruppe': getattr( + version, 'l10n_at_personalgruppe', '') + if version else '', + 'geringfuegig': slip.l10n_at_geringfuegig, + 'basis_laufen': sum( + b for _m, b in basen), + 'sz_basis': sz_basis, + 'sv_dn': sv_dn, + 'sv_dg': sv_dg, + 'sv_dn_sz': sz_dn, + 'lohnsteuer': sum( + slip.line_ids.filtered( + lambda l: l.code in ('LST_LFD', 'LST_SZ') + ).mapped('total')), + 'netto': sum( + slip.line_ids.filtered( + lambda l: l.code == 'NET').mapped('total')), + 'brutto': sum( + slip.line_ids.filtered( + lambda l: l.salary_rule_id.l10n_at_bemessung + != 'neutral').mapped('total')), + }) + return {'docs': runs, 'zeilen': zeilen} + + +class L10nAtPayrollMonatsjournal(models.AbstractModel): + _name = 'report.l10n_at_hr_payroll.report_monatsjournal' + _description = 'Monatsjournal (GP7/AP6)' + + def _get_report_values(self, docids, data=None): + runs = self.env['hr.payslip.run'].browse(docids) + zeilen = [] + for run in runs: + gruppen = {} + for slip in run.slip_ids.filtered( + lambda p: p.state not in ('draft', 'cancel')): + for line in slip.line_ids: + regel = line.salary_rule_id + if not line.total or regel.l10n_at_bemessung \ + == 'neutral': + continue + schluessel = (regel.code, regel.name, + regel.l10n_at_bemessung) + eintrag = gruppen.setdefault(schluessel, 0.0) + gruppen[schluessel] = eintrag + line.total + zeilen.append({ + 'run': run, + 'arten': [ + {'code': code, 'name': name, 'bemessung': bemessung, + 'summe': summe} + for (code, name, bemessung), summe + in sorted(gruppen.items())], + }) + return {'docs': runs, 'journal': zeilen} \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/report/belege_reports.xml b/addons/l10n_at_hr_payroll/report/belege_reports.xml new file mode 100644 index 0000000..67bda44 --- /dev/null +++ b/addons/l10n_at_hr_payroll/report/belege_reports.xml @@ -0,0 +1,203 @@ + + + + + + + + + Lohnzettel (AT) + hr.payslip + qweb-pdf + l10n_at_hr_payroll.report_lohnzettel_at + l10n_at_hr_payroll.report_lohnzettel_at + + + report + + + + Monatsdienstliste (SV-Kontrolle) + hr.payslip.run + qweb-pdf + l10n_at_hr_payroll.report_monatsdienstliste + l10n_at_hr_payroll.report_monatsdienstliste + + + report + + + + Monatsjournal + hr.payslip.run + qweb-pdf + l10n_at_hr_payroll.report_monatsjournal + l10n_at_hr_payroll.report_monatsjournal + + + report + + + + + + + + + + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/security/ir.model.access.csv b/addons/l10n_at_hr_payroll/security/ir.model.access.csv index d8644b4..482a992 100644 --- a/addons/l10n_at_hr_payroll/security/ir.model.access.csv +++ b/addons/l10n_at_hr_payroll/security/ir.model.access.csv @@ -2,4 +2,9 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_l10n_at_payroll_tasy_mapping_user,L10n AT Payroll TASY-Mapping User,model_l10n_at_payroll_tasy_mapping,hr_payroll.group_hr_payroll_user,1,0,0,0 access_l10n_at_payroll_tasy_mapping_manager,L10n AT Payroll TASY-Mapping Manager,model_l10n_at_payroll_tasy_mapping,hr_payroll.group_hr_payroll_manager,1,1,1,1 access_l10n_at_payroll_tasy_import_wizard,L10n AT Payroll TASY-Import Wizard,model_l10n_at_payroll_tasy_import_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1 -access_l10n_at_payroll_tasy_import_line,L10n AT Payroll TASY-Import Line,model_l10n_at_payroll_tasy_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1 \ No newline at end of file +access_l10n_at_payroll_tasy_import_line,L10n AT Payroll TASY-Import Line,model_l10n_at_payroll_tasy_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_l16_user,L10n AT Payroll L16 User,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_user,1,1,1,0 +access_l10n_at_payroll_l16_manager,L10n AT Payroll L16 Manager,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_manager,1,1,1,1 +access_l10n_at_payroll_l16_line_user,L10n AT Payroll L16-Line User,model_l10n_at_payroll_l16_line,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_kind_user,L10n AT Payroll Kind User,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_user,1,1,1,1 +access_l10n_at_payroll_kind_manager,L10n AT Payroll Kind Manager,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_manager,1,1,1,1 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/tests/__init__.py b/addons/l10n_at_hr_payroll/tests/__init__.py index 3b5c5c4..f690b87 100644 --- a/addons/l10n_at_hr_payroll/tests/__init__.py +++ b/addons/l10n_at_hr_payroll/tests/__init__.py @@ -1,4 +1,5 @@ # Part of the odoo-at-payroll project. License: LGPL-3. from . import test_tasy_import from . import test_lohnsteuer -from . import test_sozialversicherung \ No newline at end of file +from . import test_sozialversicherung +from . import test_elda_l16 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/tests/test_elda_l16.py b/addons/l10n_at_hr_payroll/tests/test_elda_l16.py new file mode 100644 index 0000000..6143beb --- /dev/null +++ b/addons/l10n_at_hr_payroll/tests/test_elda_l16.py @@ -0,0 +1,239 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP7 Kern: ELDA-Fixsatz-Framework (DM-ORG 42.7.0 Konventionen) und +Lohnzettel-Finanz-Generator (I1/L1, BEST LF/94) — Positionsexakte +Satz-Assertions, CRLF/ISO-8859-15-Encoding, SANR/SANZ-Kette, +KZ-245-Formel-Konsistenz aus GP3-Daten.""" +import base64 +import datetime + +from odoo.tests import TransactionCase, tagged + +from odoo.addons.l10n_at_hr_payroll.models.elda import ( + L10N_AT_ELDA_IDTEIL, + L10nAtEldaBestand, + L10nAtEldaDatei, + L10nAtEldaSatz, + l10n_at_euro_cent, +) + + +@tagged('post_install', '-at_install') +class EldaFrameworkTest(TransactionCase): + + def test_01_feldformatierung(self): + """a/n: linksbündig blank-gefüllt; n: rechtsbündig mit führenden + Nullen, keine Interpunktion; Cent kaufmännisch; skala=3 für + Prozentsätze (39,30 % → 039300).""" + satz = L10nAtEldaSatz( + [(1, 'A', 1, 5, 'a'), (2, 'N', 6, 4, 'n'), + (3, 'B', 10, 10, 'n')], 20) + satz.setze('A', 'AB') + satz.setze('N', 42) + self.assertEqual(satz.render()[:5], 'AB ') + self.assertEqual(satz.render()[5:9], '0042') + # Unbelegtes n-Feld: Grundstellung 0. + self.assertEqual(satz.render()[9:19], '0' * 10) + self.assertEqual(l10n_at_euro_cent(1234.567), 123457) + self.assertEqual(l10n_at_euro_cent(0.005), 1) + prozent = L10nAtEldaSatz( + [(1, 'VPTA', 1, 6, 'n')], 6) + prozent.setze('VPTA', 39.30, skala=3) + self.assertEqual(prozent.render(), '039300') + + def test_02_bestand_datei(self): + """Vorlaufsatz SART 00 mit BEST/VSTR/VERS, SANR lückenlos ab 1, + SANZ inklusive Vorlauf/Schluss, CRLF-getrennte Zeilen in + ISO-8859-15, optionaler Dateiheader ENCD.""" + satz1 = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + [(2, 'X', 21, 4, 'a')], 24, + bezeichnung='Test') + satz1.setze('SART', '45') + satz1.setze('VSTR', 'ST') + satz1.setze('X', 'TEST') + satz2 = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + [(2, 'Y', 21, 4, 'n')], 24, + bezeichnung='Test') + satz2.setze('SART', '45') + satz2.setze('VSTR', 'ST') + satz2.setze('Y', 7) + bestand = L10nAtEldaBestand( + 'AD', 'ST', '02', + hersteller={'name': 'Test', 'kfz': 'A'}, + datensaetze=[satz1, satz2]) + zeilen = bestand.render( + erstellungsdatum=datetime.date(2026, 1, 15)) + vorlauf = zeilen[0] + self.assertEqual(len(zeilen), 4) + self.assertEqual(vorlauf[18:20], 'ST') # VSTR (Pos. 19) + self.assertEqual(vorlauf[22:24], 'AD') # BEST + self.assertEqual(vorlauf[30:38], '15012026') # EDAT TTMMJJJJ + self.assertEqual(vorlauf[149:151], '02') # VERS + self.assertEqual(zeilen[1][0:2], '45') + self.assertEqual(zeilen[1][2:9], '0000002') # SANR + self.assertEqual(zeilen[3][20:26], '000004') # SANZ + datei = L10nAtEldaDatei([bestand]) + roh = datei.render() + self.assertIn(b'\r\n', roh) + erste = roh.split(b'\r\n')[0] + self.assertEqual(erste[:4], b' 01') + self.assertIn(b'ISO-8859-15', erste) + # Umlaut in ISO-8859-15 codiert. + satz3 = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + [(2, 'Z', 21, 10, 'a')], 30, + bezeichnung='Umlaut') + satz3.setze('SART', '45') + satz3.setze('VSTR', 'ST') + satz3.setze('Z', 'Für') + self.assertIn('Für'.encode('ISO-8859-15'), + satz3.render().encode('ISO-8859-15')) + + +@tagged('post_install', '-at_install') +class L16GeneratorTest(TransactionCase): + + def setUp(self): + super().setUp() + self.company = self.env.company + self.company.write({ + 'l10n_at_lohnsteuernummer': '123456789', + 'l10n_at_beitragskontonummer': '1412345678', + }) + self.structure = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + self.kv2203 = self.env.ref( + 'l10n_at_hr_payroll_private.kv_si_2203') + self.gruppe_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + self.calendar = self.env['resource.calendar'].create({ + 'name': 'AT Test 40h', + 'tz': 'Europe/Vienna', + 'hours_per_day': 8, + 'attendance_ids': [ + (0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd), + 'hour_from': 8.0, 'hour_to': 16.0}) + for wd in range(5) + ], + }) + self.company.resource_calendar_id = self.calendar + employee = self.env['hr.employee'].create({ + 'name': 'L16 Test', 'birthday': '1990-05-15'}) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + version.write({ + 'contract_date_start': datetime.date(2020, 1, 1), + 'ssnid': '1234150590', + 'sex': 'female', + 'kv_id': self.kv2203.id, + 'kv_gruppe_id': self.gruppe_ii.id, + 'erfahrungsstufe': 1, + 'resource_calendar_id': self.calendar.id, + }) + self.employee = employee + self.version = version + + def _slip(self, monat, jahr=2026, confirm=True): + erster = datetime.date(jahr, monat, 1) + letzter = (erster.replace(day=28) + datetime.timedelta(days=4)) \ + .replace(day=1) - datetime.timedelta(days=1) + slip = self.env['hr.payslip'].create({ + 'name': 'L16 %d/%d' % (monat, jahr), + 'employee_id': self.employee.id, + 'struct_id': self.structure.id, + 'date_from': erster, + 'date_to': letzter, + }) + slip.compute_sheet() + if confirm: + slip.action_payslip_done() + return slip + + def test_01_l16_lauf_und_positionen(self): + """Jahreslauf: KZ-Salden aus GP3/GP4-Flags, L1-Record mit + exakten Positionen (Satzlänge 3500, REFN/ARTL/KZ-V/B-Paare, + SOZS/AVLN/AGBD), I1 (1100, STNRA/JAHR/GESA/ANZA), Datei mit + LF-Bestand VSTR 94, KZ-245-Konsistenz per Konstruktion.""" + self._slip(11) # November mit B13 + deklaration = self.env['l10n.at.payroll.l16'].create({ + 'company_id': self.company.id, + 'jahr': 2026, + 'art': '01', + }) + deklaration.action_erzeugen() + self.assertEqual(deklaration.state, 'done') + self.assertEqual(len(deklaration.line_ids), 1) + line = deklaration.line_ids + # November: KZ 210 = KVGEO + B13 = 4.118,82 (§ 25 gesamt); + # KZ 220 = B13 = 2.059,41; KZ 245 = laufende Bemessung. + self.assertAlmostEqual(line.kz210, 4118.82, places=2) + self.assertAlmostEqual(line.kz220, 2059.41, places=2) + # KZ-245-Formel: 210 − 215 − 220 − 230 − 243 + erwartet = (line.kz210 - line.kz215 - line.kz220 + - line.kz230 - line.kz243) + self.assertAlmostEqual(line.kz245, erwartet, places=1) + # Datei: I1 (1100) + L1 (3500) + Vorlauf/Schluss (auf 3500 + # aufgefüllt), LF-Bestand, VSTR 94, Lohnzettelversion 28. + roh = base64.b64decode(deklaration.datei_l16).decode( + 'ISO-8859-15') + zeilen = roh.strip('\r\n').split('\r\n') + # Dateiheader + Vorlauf + I1 + L1 + Schluss. + self.assertEqual(len(zeilen), 5) + header, vorlauf, i1, l1, schluss = zeilen + self.assertEqual(header[:4], ' 01') + self.assertIn('ISO-8859-15', header) + self.assertEqual(len(i1), 1100) + self.assertEqual(len(l1), 3500) + self.assertEqual(vorlauf[0:2], '00') + self.assertEqual(vorlauf[18:20], '94') + self.assertEqual(vorlauf[22:24], 'LF') + self.assertEqual(vorlauf[149:151], '28') + self.assertEqual(i1[18:20], '94') + self.assertEqual(i1[20:21], 'I') + self.assertEqual(i1[34:43], '123456789') # STNRL + self.assertEqual(i1[50:59], '123456789') # STNRA + self.assertEqual(i1[82:84], '03') # STVE + self.assertEqual(i1[232:236], '2026') # JAHR + self.assertEqual(i1[236:243], '0000001') # GESA + self.assertEqual(l1[18:20], '94') + self.assertEqual(l1[20:21], 'L') + self.assertEqual(l1[95:97], '01') # ARTL + self.assertEqual(l1[119:123], '0111') # BELZ TTMM + self.assertEqual(l1[123:127], '3011') # ENLZ + self.assertEqual(l1[127:131], '2026') # JALZ + self.assertEqual(l1[131:132], '3') # SOZS Angestellte + self.assertEqual(l1[132:136], '1234') # AVLN LLLP + self.assertEqual(l1[136:142], '150590') # AGBD TTMMJJ + # KZ-Felder: V/B-Paare (1-based-Positionen → Index pos−1). + self.assertEqual(l1[296:297], '+') # V210 (Pos. 297) + self.assertEqual(l1[297:307], '0000411882') # B210 + self.assertEqual(l1[318:319], '+') # V220 (Pos. 319) + self.assertEqual(l1[319:329], '0000205941') # B220 + self.assertEqual(l1[450:451], '+') # V245 (Pos. 451) + b245 = '%010d' % int(round(line.kz245 * 100)) + self.assertEqual(l1[451:461], b245) + self.assertEqual(schluss[0:2], '99') + self.assertEqual(schluss[20:26], '000004') # SANZ + + def test_02_arbeitsstaette(self): + """§ 34 Abs 6 ASVG: je AN ein Satz 45 (Länge 300) mit BKNR, + VSNR, STNR und GKZ aus dem Dienstort-Partner; Warnung, wenn + die GKZ fehlt.""" + self._slip(1) + deklaration = self.env['l10n.at.payroll.l16'].create({ + 'company_id': self.company.id, + 'jahr': 2026, + 'art': '01', + }) + deklaration.action_erzeugen() + self.assertTrue(deklaration.datei_arbeitsstaette) + roh = base64.b64decode( + deklaration.datei_arbeitsstaette).decode('ISO-8859-15') + zeilen = roh.strip('\r\n').split('\r\n') + vorlauf, satz45 = zeilen[1], zeilen[2] + self.assertEqual(vorlauf[18:20], 'ST') + self.assertEqual(vorlauf[22:24], 'AD') + self.assertEqual(satz45[0:2], '45') + self.assertEqual(len(satz45), 300) + self.assertEqual(satz45[22:32], '1412345678') # BKNR + self.assertEqual(satz45[32:42], '1234150590') # VSNR + self.assertEqual(satz45[52:61], '123456789') # STNR + self.assertIn('Gemeindekennziffer', deklaration.protokoll) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/views/meldewesen_views.xml b/addons/l10n_at_hr_payroll/views/meldewesen_views.xml new file mode 100644 index 0000000..9ef7be2 --- /dev/null +++ b/addons/l10n_at_hr_payroll/views/meldewesen_views.xml @@ -0,0 +1,127 @@ + + + + + + + + + l10n.at.payroll.l16.list + l10n.at.payroll.l16 + + + + + + + + + + + + l10n.at.payroll.l16.form + l10n.at.payroll.l16 + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Lohnzettel Finanz (L16) + l10n.at.payroll.l16 + list,form + + + + + + + + + hr.employee.form.l10n_at_payroll_kind + hr.employee + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file