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
+
+
+
+
+
+
+
+
+
Lohnzettel
+
+
+ | Mitarbeiter:in |
+ |
+ Versicherungsnummer |
+ |
+
+
+ | Abrechnungsperiode |
+ – |
+ Personalgruppe |
+ |
+
+
+
Bezüge
+
+
+
+ | Lohnart |
+ Betrag |
+
+
+
+
+ |
+
+
+ |
+
+
+ | Brutto |
+
+
+ |
+
+
+
+
Abzüge (Dienstnehmer:in)
+
+
+
+ | Abzug |
+ Betrag |
+
+
+
+
+ |
+
+
+ |
+
+
+
+
Dienstgeberanteile
+
+
+
+ | DG-Komponente |
+ Betrag |
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+ Netto:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Monatsdienstliste —
+
+
+
+ | Mitarbeiter:in |
+ VSNR |
+ Gruppe |
+ BG laufend |
+ BG SZ |
+ SV-DN |
+ SV-DN SZ |
+ SV-DG |
+ Lohnsteuer |
+ Netto |
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Monatsjournal —
+
+
+
+ | Code |
+ Lohnart |
+ Bemessung |
+ Summe |
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
\ 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