[IMP] l10n_at_hr_payroll: GP7 ELDA-Fixsatz-Framework, L16 + Arbeitsstättenmeldung, Kind-Modell, Belege (AP5/AP6)

ELDA-Fixsatz-Framework (models/elda.py) nach DM-ORG 42.7.0 Kapitel C.1/C.2+E.1-E.3: Fixlängen-Sätze ohne Feldtrennzeichen (a/a-n linksbündig, Grundstellung blank; n rechtsbündig, Grundstellung 0, führende Nullen, KEINE Interpunktion — Beträge EURO-Cent kaufmännisch gerundet, Prozentsätze x1000 mit 3 Nachkommastellen), Encoding ISO-8859-15 (Standard-Encoding für Fixlängen), optionaler Dateiheader (DHKZ 2x blank + DHVN 01 + ENCD), Vorlaufsatz SART 00 (BEST/VSTR/VERS) + Schlusssatz SART 99 mit SANZ; SANR lückenlos ab 1 je Datenbestand; CRLF als Satztrenner (Praxis-Annahme, DM-ORG normiert keines — Parallellauf verifizieren).

Lohnzettel Finanz L16 (models/lohnzettel_l16.py): Informationssatz I1 (SART I1, Länge 1.100, Strukturversion 03) + Mitteilungssatz L1 (SART L1, Länge 3.500, Lohnzettelversion 28 — zwingend ab 01.03.2026) mit vollständiger, PDF-verifizierter Feldtabelle (~210 Felder inkl. Kinderblock 15x123 ab Pos. 1501; DM-ORG 42.7.0 E.13/E.14). KZ-245-Formel: KZ 210 = Bruttobezüge gem. § 25 GESAMT (DM-ORG-Feldbeschreibung) — die Formel 210-215-220-230-243 ergibt die laufende Steuerbemessung, per Konstruktion konsistent mit der GP3-§-66-Kette (Ableitung aus l10n_at_bemessung/steuerbefreiung_68/s67_abs und Regelcodes; bewusst KEIN paralleles KZ-Klassifikationsfeld). Arbeitsstättenmeldung § 34 Abs 6 ASVG: SART 45 (Länge 300, Version 02, BEST AD/VSTR ST Statistik Austria) je AN am 31.12. aus dem Dienstort-Partner (GKZ manuell, Warnung wenn fehlt) — im selben Jahreslauf (Nutzer-Entscheidung 5.1).

Kind-Datenmodell (models/kind.py — l10n.at.payroll.kind): Stammdaten-Wartung des Familienbonus Plus (Nutzer-Entscheidung 5.3: FB+ rechnet NICHT im Lohnsteuerkern — bei Unterschreiten der Steuer auf 0 erfolgt die Geltendmachung über die Arbeitnehmerveranlagung); Felder für den L16-Kinderblock (Familienname/Vorname/Geburtsdatum/VSNR/Staatsangehörigkeit/FB+-Monate voll/halb, Alterstaffel 166,68/58,34).

Stammdaten (models/meldewesen.py): Beitragskontonummer BKNR + Lohnsteuernummer (res.company), Gemeindekennziffer GKZ (res.partner), VSNR-Formatprüfung LLLPTTMMJJ auf hr.version.ssnid (Prüfzifferalgorithmus = dokumentierter Rest, SVS-Quelle beschaffen), eSV-Abmeldegrund AGRD-Mapping auf hr.departure.reason (Standard 00); fallweise-Beschäftigungs-Flag auf hr.version (§ 33 Abs 1a Z 1 ASVG).

Belege (AP6): Lohnzettel AT (ir.actions.report auf hr.payslip, AT-Layout mit Bezügen/DN-Abzügen/DG-Anteilen/Netto), Monatsdienstliste (mBGM-Kontrollschicht je AN) + Monatsjournal (je Lohnart Summen je Lauf) auf hr.payslip.run als QWeb (models/monatsreports.py + report/belege_reports.xml; account.report wird in keinem HR-Modul genutzt).

Tests: test_elda_l16 (4 — Feldformatierung/Cent-Beträge/skala, Bestands-Assembly SANR/SANZ/CRLF/ISO-8859-15/Dateiheader, L16-Lauf mit positionsexakten Assertions aus der November-Fixture, Arbeitsstätten-Satz). 19.0.4.0.0.
This commit is contained in:
2026-09-12 17:30:59 +02:00
parent 529ac30cdb
commit c474a39466
12 changed files with 1925 additions and 5 deletions
+24 -2
View File
@@ -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',
+4 -1
View File
@@ -2,4 +2,7 @@
from . import tasy
from . import lohnsteuer
from . import sozialversicherung
from . import sozialversicherung
from . import elda
from . import meldewesen
from . import kind
from . import lohnzettel_l16
+256
View File
@@ -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.1E.3 — verifiziert 2026-09-12):
- Datei = optionaler Dateiheader (DHKZ 2×blank + DHVN 01 + ENCD) + mindestens
ein Datenbestand; Datenbestand = Vorlaufsatz (SART 00, Feld VERS = Version
der nachfolgenden Satzstrukturen) + Datensätze + Schlusssatz (SART 99,
SANZ = Satzanzahl inkl. Vorlauf und Schluss).
- Fixlängen-Sätze ohne Feldtrennzeichen; alle Sätze eines Bestands nehmen
die maximale Satzlänge des Bestands an (RESE-Auffüllung). Sätze werden
zeilenweise mit CRLF getrennt (die DM-ORG normiert KEIN explizites
Satztrennzeichen — CRLF ist die Praxis der ELDA-Kanäle; Annahme im
Parallellauf gegen den Mandanten-ELDA-Zugang verifizieren).
- ``a``/``a/n``: linksbündig, Grundstellung blank; ``n``: rechtsbündig,
Grundstellung 0, führende Nullen, KEINE Interpunktion (Beträge als
EURO-Cent, kaufmännisch gerundet; Prozentsätze mit impliziten
Nachkommastellen via ``skala``).
- Encoding Fixlängen-Dateien: ISO-8859-15 (Standard-Encoding; der
Dateiheader deklariert ENCD). Vorzeichen gehören in eigene V-Felder
(RSVZ/Vxxx), ``n``-Felder sind nie negativ.
"""
from datetime import date, datetime
from decimal import ROUND_HALF_UP, Decimal
L10N_AT_ELDA_ENCODING = 'ISO-8859-15'
L10N_AT_ELDA_ZEILENTRENNER = '\r\n'
# Identifikationsteil (E.1, S. 174) — 20 Stellen, alle Satzarten gleich;
# alle Angaben zwingend.
L10N_AT_ELDA_IDTEIL = [
(1, 'SART', 1, 2, 'a'), # Satzart
(2, 'SANR', 3, 7, 'n'), # laufende Satznummer je Bestand, ab 1
(3, 'UVST', 10, 2, 'a'), # datenübernehmender Träger (ED = ELDA)
(4, 'OBUS', 12, 7, 'n'), # Ordnungsbegriff der übermittelnden Stelle
(5, 'VSTR', 19, 2, 'a'), # zuständiger Träger (D.4)
]
# Vorlaufsatz-Datenteil (E.2, S. 175177, Version 08) — folgt dem IDTEIL;
# zwingend außer HTEL/SOID/VNMF; Satzlänge = maximale Satzlänge des Bestands.
L10N_AT_ELDA_VORLAUF = [
(2, 'PROJ', 21, 2, 'a'), # DM; TM = Testdaten
(3, 'BEST', 23, 2, 'a'), # Verarbeitung (MB/VR/LF/AD/MA …, E.2)
(4, 'DTNR', 25, 6, 'a'), # Datenträgernummer (TP-Übermittlung: frei)
(5, 'EDAT', 31, 8, 'n'), # TTMMJJJJ
(6, 'EZEI', 39, 6, 'n'), # HHMMSS
(7, 'HRST', 45, 45, 'a'), # Herstellername
(8, 'HKFZ', 90, 3, 'a'), # Internationales Kfz-Kennzeichen (A)
(9, 'HPLZ', 93, 7, 'a'),
(10, 'HORT', 100, 20, 'a'),
(11, 'HSTR', 120, 30, 'a'),
(12, 'VERS', 150, 2, 'n'), # Version der Satzstrukturen (D.26)
(13, 'HTEL', 152, 20, 'a'),
(14, 'SOID', 172, 70, 'a'),
(15, 'VNMF', 242, 5, 'a'), # Mitteilungsfile-Version
]
# Schlusssatz-Datenteil (E.3, S. 178, Version 01) — nach IDTEIL;
# SANZ zwingend, ELNR/HEMA optional.
L10N_AT_ELDA_SCHLUSS = [
(2, 'SANZ', 21, 6, 'n'), # Satzanzahl inkl. Vorlauf- und Schlusssatz
(3, 'ELNR', 27, 6, 'n'),
(4, 'HEMA', 33, 60, 'a'),
]
def l10n_at_euro_cent(betrag):
"""EURO-Betrag → Cent (kaufmännisch gerundet; DM-ORG: keine
Interpunktion)."""
return int((Decimal(str(betrag)) * 100).quantize(
Decimal('1'), rounding=ROUND_HALF_UP))
class L10nAtEldaSatz:
"""Ein Fixlängen-Satz: Felddaten (Nr, Name, Pos, Länge, Typ), zwingende
Grundstellung je Typ, Rendering auf die Bestands-Satzlänge. Kein ORM —
reine Bau-Hilfe der Meldungsgeneratoren (DmfA-Muster: Datensatz-Baum im
Python-Teil, Validierung/Prüfprotokoll darüber)."""
def __init__(self, struktur, satzlaenge, bezeichnung=''):
self.struktur = list(struktur)
self.satzlaenge = satzlaenge
self.bezeichnung = bezeichnung
self._felder = {f[1]: f for f in self.struktur}
self._werte = {}
if self.struktur:
letzte = max(f[2] + f[3] - 1 for f in self.struktur)
if letzte > satzlaenge:
raise ValueError(
'Struktur %s endet bei Position %d — Satzlänge %d '
'überschritten' % (bezeichnung, letzte, satzlaenge))
def setze(self, name, wert, cent=False, skala=0):
"""Feld setzen; ``wert`` None übernimmt die Grundstellung.
``cent``: EURO-Betrag → Cent; ``skala``: implizite Nachkommastellen
(z. B. Prozentsatz mit 3 Nachkommastellen → skala=3)."""
if wert is None:
return self
nr, fname, pos, laenge, typ = self._felder[name]
if cent:
wert = l10n_at_euro_cent(wert)
if isinstance(wert, float):
wert = int((Decimal(str(wert)) * (10 ** skala)).quantize(
Decimal('1'), rounding=ROUND_HALF_UP))
elif isinstance(wert, int):
wert = wert * (10 ** skala)
roh = str(wert)
if typ == 'n':
if not roh.isdigit():
raise ValueError('Feld %s (%s): %r ist nicht numerisch' % (
name, self.bezeichnung, roh))
roh = roh.rjust(laenge, '0')
else:
roh = roh.ljust(laenge, ' ')
if len(roh) > laenge:
raise ValueError('Feld %s (%s): %r länger als %d' % (
name, self.bezeichnung, roh, laenge))
self._werte[name] = roh
return self
def setze_datum(self, name, wert, fmt='TTMMJJJJ'):
"""Datum in DM-ORG-Feldformat (Vorlaufsatz EDAT = TTMMJJJJ,
L16-Übermittlungsfelder = JJJJMMTT, LZP/AGBD = TTMMJJ)."""
if wert is None:
return self
if isinstance(wert, str):
wert = datetime.strptime(wert, '%Y-%m-%d').date()
formate = {'TTMMJJJJ': '%d%m%Y', 'JJJJMMTT': '%Y%m%d',
'TTMMJJ': '%d%m%y'}
if fmt not in formate:
raise ValueError('Unbekanntes Datumsformat %s' % fmt)
return self.setze(name, wert.strftime(formate[fmt]))
def setze_zeit(self, name, wert):
if wert is None:
return self
return self.setze(name, wert.strftime('%H%M%S'))
def ist_gesetzt(self, name):
return name in self._werte
def wert(self, name):
return self._werte.get(name)
def render(self):
"""Satz in Bestands-Satzlänge; unbelegte Felder in Grundstellung
(``a`` blank / ``n`` 0), Positionen nach dem letzten Feld blank."""
puffer = [' '] * self.satzlaenge
for nr, name, pos, laenge, typ in self.struktur:
roh = self._werte.get(name)
if roh is None:
roh = '0' * laenge if typ == 'n' else ' ' * laenge
puffer[pos - 1:pos - 1 + laenge] = roh
return ''.join(puffer)
class L10nAtEldaBestand:
"""Datenbestand: Vorlaufsatz + Datensätze + Schlusssatz zu EINER
Verarbeitung (BEST) für EINEN zuständigen Träger (VSTR). SANR zählt
lückenlos ab 1, SANZ inklusive Vorlauf/Schluss (Prüfvorschrift C.1.2);
alle Sätze nehmen die maximale Satzlänge der Datensätze an."""
def __init__(self, best, vstr, vers, hersteller=None, test=False,
obus='0000000', datensaetze=None):
self.best = best
self.vstr = vstr
self.vers = vers
self.hersteller = hersteller or {}
self.projekt = 'TM' if test else 'DM'
self.obus = obus
self.datensaetze = datensaetze or []
def ergaenze(self, satz):
self.datensaetze.append(satz)
return satz
def render(self, erstellungsdatum=None):
"""Zeilen des Bestands (ohne Zeilentrenner)."""
if not self.datensaetze:
raise ValueError('Datenbestand %s ohne Datensätze' % self.best)
heute = erstellungsdatum or date.today()
satzlaenge = max(s.satzlaenge for s in self.datensaetze)
# Der Vorlaufsatz nimmt die Satzlänge des Bestands an — aber
# nie kürzer als seine eigene Struktur (DM-ORG E.2: Vorlauf
# trägt alle Herstellfelder; reale Bestände sind ≥ 246).
satzlaenge = max(
satzlaenge,
max(f[2] + f[3] - 1 for f in
(L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF)))
zeilen = []
vorlauf_struktur = [
f for f in (L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF)
if f[2] + f[3] - 1 <= satzlaenge]
vorlauf = L10nAtEldaSatz(
vorlauf_struktur, satzlaenge,
bezeichnung='Vorlaufsatz %s' % self.best)
vorlauf.setze('SART', '00')
vorlauf.setze('SANR', 1)
vorlauf.setze('UVST', 'ED')
vorlauf.setze('OBUS', self.obus)
vorlauf.setze('VSTR', self.vstr)
vorlauf.setze('PROJ', self.projekt)
vorlauf.setze('BEST', self.best)
vorlauf.setze_datum('EDAT', heute, 'TTMMJJJJ')
vorlauf.setze_zeit('EZEI', datetime.now())
vorlauf.setze('HRST', (self.hersteller.get('name') or '')[:45])
vorlauf.setze('HKFZ', self.hersteller.get('kfz', 'A'))
vorlauf.setze('HPLZ', self.hersteller.get('plz'))
vorlauf.setze('HORT', self.hersteller.get('ort'))
vorlauf.setze('HSTR', self.hersteller.get('strasse'))
vorlauf.setze('VERS', self.vers)
vorlauf.setze('VNMF', '3.0')
zeilen.append(vorlauf.render())
for nummer, satz in enumerate(self.datensaetze, start=2):
# Datensatz-Generatoren setzen ihre SART selbst; der Bestand
# ergänzt Trägerangaben (falls offen) und die laufende
# Satznummer (C.1.2: lückenlos ab 1).
if not satz.ist_gesetzt('UVST'):
satz.setze('UVST', 'ED')
if not satz.ist_gesetzt('OBUS'):
satz.setze('OBUS', self.obus)
if not satz.ist_gesetzt('VSTR'):
satz.setze('VSTR', self.vstr)
satz._werte['SANR'] = str(nummer).rjust(7, '0')
zeilen.append(satz.render())
schluss = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_SCHLUSS, satzlaenge,
bezeichnung='Schlusssatz %s' % self.best)
schluss.setze('SART', '99')
schluss.setze('SANR', len(self.datensaetze) + 2)
schluss.setze('UVST', 'ED')
schluss.setze('OBUS', self.obus)
schluss.setze('VSTR', self.vstr)
schluss.setze('SANZ', len(self.datensaetze) + 2)
zeilen.append(schluss.render())
return zeilen
class L10nAtEldaDatei:
"""ELDA-Datei: Dateiheader (ENCD-Standard ISO-8859-15) + Datenbestände;
Rendering in Bytes (Encoding + CRLF-Zeilentrenner, letzte Zeile mit
Abschluss-Trenner)."""
def __init__(self, bestaende=None, header=True):
self.bestaende = bestaende or []
self.header = header
def render(self):
zeilen = []
if self.header:
zeilen.append(' 01' + L10N_AT_ELDA_ENCODING.ljust(50))
for bestand in self.bestaende:
zeilen.extend(bestand.render())
roh = L10N_AT_ELDA_ZEILENTRENNER.join(zeilen) \
+ L10N_AT_ELDA_ZEILENTRENNER
return roh.encode(L10N_AT_ELDA_ENCODING)
+104
View File
@@ -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. 196209: 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 (112), 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 (112) des vollen Familienbonus Plus (L16 '
'KEGFP).')
fab_halb_monat_von = fields.Integer(
string='Halber FB+ ab Monat (KBHFP)',
help='Monat (112), 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 (112) 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))
@@ -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. 219220; 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. 223233; 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 38
(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 196209): je Kind 123 Stellen ab Pos. 1501
# (Block 1 = 15011623 … Block 15 = 32233345; 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='08 (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
@@ -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 510). 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 510).'
% 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“.')
@@ -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}
@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP7/AP6 Belege: Lohnzettel AT (am Lohnzettel/Payslip), -->
<!-- Monatsdienstliste + Monatsjournal (am Lohnlauf). -->
<!-- ============================================================ -->
<record id="action_report_lohnzettel_at" model="ir.actions.report">
<field name="name">Lohnzettel (AT)</field>
<field name="model">hr.payslip</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll.report_lohnzettel_at</field>
<field name="report_file">l10n_at_hr_payroll.report_lohnzettel_at</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_monatsdienstliste" model="ir.actions.report">
<field name="name">Monatsdienstliste (SV-Kontrolle)</field>
<field name="model">hr.payslip.run</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll.report_monatsdienstliste</field>
<field name="report_file">l10n_at_hr_payroll.report_monatsdienstliste</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_monatsjournal" model="ir.actions.report">
<field name="name">Monatsjournal</field>
<field name="model">hr.payslip.run</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll.report_monatsjournal</field>
<field name="report_file">l10n_at_hr_payroll.report_monatsjournal</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
<field name="binding_type">report</field>
</record>
<!-- Lohnzettel AT: AT-Layout — Lohnarten nach Kategorie, DN-Abzüge,
DG-Anteile (SV-DG, FLAF, KommSt, DZ, BV), Netto. -->
<template id="report_lohnzettel_at">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="slip">
<t t-call="web.external_layout">
<div class="page">
<h2>Lohnzettel</h2>
<table class="table table-sm">
<tr>
<td><strong>Mitarbeiter:in</strong></td>
<td><t t-out="slip.employee_id.name"/></td>
<td><strong>Versicherungsnummer</strong></td>
<td><t t-out="slip.version_id.ssnid or ''"/></td>
</tr>
<tr>
<td><strong>Abrechnungsperiode</strong></td>
<td><t t-out="slip.date_from"/> <t t-out="slip.date_to"/></td>
<td><strong>Personalgruppe</strong></td>
<td><t t-out="slip.version_id.l10n_at_personalgruppe or ''"/></td>
</tr>
</table>
<h3>Bezüge</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Lohnart</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Brutto</strong></td>
<td class="text-end">
<strong><span t-out="sum(slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO').mapped('total'))" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/></strong>
</td>
</tr>
</tbody>
</table>
<h3>Abzüge (Dienstnehmer:in)</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Abzug</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.category_id.code == 'ATP_ABZUG')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="-line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<h3>Dienstgeberanteile</h3>
<table class="table table-sm">
<thead>
<tr>
<th>DG-Komponente</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.category_id.code == 'ATP_DG')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<p class="text-end h4">
<strong>Netto:</strong>
<span t-out="sum(slip.line_ids.filtered(lambda l: l.code == 'NET').mapped('total'))" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</p>
</div>
</t>
</t>
</t>
</template>
<!-- Monatsdienstliste: je AN die mBGM-Kontrollgrößen -->
<template id="report_monatsdienstliste">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="run">
<t t-call="web.external_layout">
<div class="page">
<h2>Monatsdienstliste — <t t-out="run.name"/></h2>
<table class="table table-sm">
<thead>
<tr>
<th>Mitarbeiter:in</th>
<th>VSNR</th>
<th>Gruppe</th>
<th class="text-end">BG laufend</th>
<th class="text-end">BG SZ</th>
<th class="text-end">SV-DN</th>
<th class="text-end">SV-DN SZ</th>
<th class="text-end">SV-DG</th>
<th class="text-end">Lohnsteuer</th>
<th class="text-end">Netto</th>
</tr>
</thead>
<tbody>
<tr t-foreach="zeilen" t-as="z">
<td><t t-out="z['slip'].employee_id.name"/></td>
<td><t t-out="z['versicherungsnummer']"/></td>
<td><t t-out="z['personalgruppe']"/><t t-if="z['geringfuegig']" t-out=" (geringf.)"/></td>
<td class="text-end"><span t-out="z['basis_laufen']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sz_basis']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sv_dn']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sv_dn_sz']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sv_dg']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['lohnsteuer']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['netto']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
<!-- Monatsjournal: je Lohnart die Summe über den Lauf -->
<template id="report_monatsjournal">
<t t-call="web.html_container">
<t t-foreach="journal" t-as="j">
<t t-call="web.external_layout">
<div class="page">
<h2>Monatsjournal — <t t-out="j['run'].name"/></h2>
<table class="table table-sm">
<thead>
<tr>
<th>Code</th>
<th>Lohnart</th>
<th>Bemessung</th>
<th class="text-end">Summe</th>
</tr>
</thead>
<tbody>
<tr t-foreach="j['arten']" t-as="art">
<td><t t-out="art['code']"/></td>
<td><t t-out="art['name']"/></td>
<td><t t-out="art['bemessung']"/></td>
<td class="text-end"><span t-out="art['summe']" t-options="{&quot;widget&quot;: &quot;monetary&quot;, &quot;display_currency&quot;: j['run'].company_id.currency_id}"/></td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>
@@ -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
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
+2 -1
View File
@@ -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
from . import test_sozialversicherung
from . import test_elda_l16
@@ -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 pos1).
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)
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP7 Meldewesen: L16-/Arbeitsstätten-Deklaration + Kind-Stamm -->
<!-- (FB+-Wartung am Mitarbeiter). -->
<!-- ============================================================ -->
<record id="l16_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.l16.list</field>
<field name="model">l10n.at.payroll.l16</field>
<field name="arch" type="xml">
<list string="Lohnzettel Finanz (L16)">
<field name="jahr"/>
<field name="art"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="l16_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.l16.form</field>
<field name="model">l10n.at.payroll.l16</field>
<field name="arch" type="xml">
<form string="Lohnzettel Finanz (L16) &amp; Arbeitsstättenmeldung">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (Dateien)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="jahr"/>
<field name="art"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Mitteilungssätze" name="linien">
<field name="line_ids">
<list editable="bottom">
<field name="employee_name"/>
<field name="versicherungsnummer"/>
<field name="soziale_stellung"/>
<field name="kz210" optional="show"/>
<field name="kz215" optional="hide"/>
<field name="kz220" optional="show"/>
<field name="kz230" optional="hide"/>
<field name="kz245" optional="show"/>
<field name="lohnsteuer"/>
<field name="fab_betrag"
string="FB+"/>
</list>
</field>
</page>
<page string="Dateien" name="dateien">
<group>
<field name="datei_l16"
filename="datei_l16_name"/>
<field name="datei_l16_name" invisible="1"/>
<field name="datei_arbeitsstaette"
filename="datei_arbeitsstaette_name"/>
<field name="datei_arbeitsstaette_name"
invisible="1"/>
</group>
</page>
<page string="Prüfprotokoll" name="protokoll">
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_l16" model="ir.actions.act_window">
<field name="name">Lohnzettel Finanz (L16)</field>
<field name="res_model">l10n.at.payroll.l16</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_l10n_at_payroll_meldewesen"
name="Meldewesen (ELDA/FinOnl)"
parent="hr_work_entry_enterprise.menu_hr_payroll_root"
sequence="97"/>
<menuitem id="menu_l10n_at_payroll_l16"
name="Lohnzettel Finanz (L16)"
parent="menu_l10n_at_payroll_meldewesen"
action="action_l16" sequence="10"/>
<!-- Kind-Stammdaten (FB+-Wartung) als Seite am Mitarbeiterformular -->
<record id="hr_employee_view_form_kind" model="ir.ui.view">
<field name="name">hr.employee.form.l10n_at_payroll_kind</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<notebook position="inside">
<page string="Kinder (FB+)" name="l10n_at_payroll_kinder"
groups="hr_payroll.group_hr_payroll_user">
<field name="l10n_at_payroll_kind_ids">
<list editable="bottom">
<field name="familienname"/>
<field name="vorname"/>
<field name="geburtstag"/>
<field name="versicherungsnummer"/>
<field name="fab_plus" widget="boolean_toggle"/>
<field name="fab_monat_von"/>
<field name="fab_monat_bis"/>
<field name="fab_halb_monat_von"/>
<field name="fab_halb_monat_bis"/>
</list>
</field>
</page>
</notebook>
</field>
</record>
</odoo>