diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py
index b654ede..e76a513 100644
--- a/addons/l10n_at_hr_payroll_private/__manifest__.py
+++ b/addons/l10n_at_hr_payroll_private/__manifest__.py
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
- 'version': '19.0.8.1.0',
+ 'version': '19.0.9.0.0',
'author': 'ixsol / gem360',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -91,7 +91,8 @@ Kein VRV-Bezug (Gemeinde-Schiene bleibt ``l10n_at_gemeinde_payroll`` +
gem360-Brücke).
""",
'depends': ['l10n_at_hr_payroll', 'hr_payroll_account',
- 'l10n_at', 'hr_payroll_account_iso20022'],
+ 'l10n_at', 'hr_payroll_account_iso20022',
+ 'hr_holidays', 'hr_work_entry_holidays'],
'data': [
'security/ir.model.access.csv',
'security/security_rules.xml',
@@ -101,6 +102,7 @@ gem360-Brücke).
'data/l10n.at.payroll.kv.wert.csv',
'data/structure_private.xml',
'data/work_entry_types_private.xml',
+ 'data/absence_private.xml',
'data/input_types_private.xml',
'data/salary_rules_private.xml',
'data/salary_rules_lst_private.xml',
diff --git a/addons/l10n_at_hr_payroll_private/data/absence_private.xml b/addons/l10n_at_hr_payroll_private/data/absence_private.xml
new file mode 100644
index 0000000..f2f5d0e
--- /dev/null
+++ b/addons/l10n_at_hr_payroll_private/data/absence_private.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Urlaub (UrlG)
+ 3
+ URL
+ ATP_URLAUB
+
+
+
+
+ Krankenstand (EFZG)
+ 2
+ KRK
+ ATP_KRANK
+
+
+
+
+
+ Arbeitsunfall/Berufskrankheit (EFZG)
+ 9
+ AU
+ ATP_AU
+
+
+
+
+ Unbezahlte Karenz (MSchG/VKG)
+ 6
+ KAR
+ ATP_KARENZ
+
+
+
+
+
+
+ Urlaub (UrlG)
+
+
+ both
+ hr
+ day
+
+
+
+
+
+ Krankenstand
+
+ hr
+ day
+
+
+
+
+
+
+ Arbeitsunfall/Berufskrankheit
+
+ hr
+ day
+
+
+
+
+
+
+ Unbezahlte Karenz
+
+ hr
+ day
+
+
+
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/models/mbgm.py b/addons/l10n_at_hr_payroll_private/models/mbgm.py
index 4bcd0a3..6f96f48 100644
--- a/addons/l10n_at_hr_payroll_private/models/mbgm.py
+++ b/addons/l10n_at_hr_payroll_private/models/mbgm.py
@@ -9,11 +9,21 @@ Hierarchie: Paket (SART ``PS`` Selbstabrechner, Länge 305) → mBGM
Fachliche Grundlagen:
- **KEIN DN/DG- oder Zweig-Split**: die Standardposition ``T01``
- trägt den GESAMT-Prozentsatz des Tarifsystems (Parameter
- ``at_mbgm_tarif`` — BSGR-spezifisch; Werte aus dem ÖGK-
- Beitragstarifsystem, `.additional_docs/oegk/`, jeweils
- parametrisiert zu pflegen). Fehlende BSGR-Sätze brechen den Lauf
+ trägt den GESAMT-Prozentsatz des Tarifsystems. Quelle ist der
+ TASY-Katalog (AP0, Variante b): Beschäftigtengruppe + Ergänzungen
+ (ERGB am Vertrag) + Dienstgebergruppe (Company) → Tarifgruppe →
+ Tarifset; fehlt der Katalog, greift der Hand-Parameter
+ ``at_mbgm_tarif`` als Fallback (Werte aus dem ÖGK-Handbuch,
+ `.additional_docs/oegk/`). Fehlende Sätze brechen den Lauf
listenweise ab (keine Erfindung von Tarifen).
+- **BSGR** (DM-ORG D.48/TASY): B001 Arbeiter, B002 Angestellte,
+ B045 Arbeiterlehrlinge, B044 Angestelltenlehrlinge, B010 gering-
+ fällige Arbeiter, B030 geringfügige Angestellte — TASY-Bezeichner
+ verifiziert (B044 = „Ang. Lg.“, B045 = „Arb. Lg.“).
+- **ERGB** (D.49): Ergänzungen am Vertrag (z. B. E01 Nachtschwer-
+ arbeit, E02 Schlechtwetterentschädigung) werden im Tarifblock
+ belegt und steuern die Tarifgruppen-Auflösung im Katalog; das
+ Company-Flag ``l10n_at_nschab`` bleibt Fallback ohne Katalog.
- **Abschläge als eigene Verrechnungspositionen** (D.60, verifiziert):
A01/A02/A03 = Minderung AV auf 2/1/0 % (§ 2a AMPFG, DN-seitig —
Abschlag = AB × (2,95 − Zielsatz) %), A09 = UV-Entfall ab 60
@@ -171,27 +181,54 @@ class L10nAtPayrollMbgm(models.Model):
('state', 'in', ('validated', 'paid'))], limit=1)
def _l10n_at_bsgr(self, version, slip):
- """Beschäftigtengruppe (SV-Tarifsystem): B001 Arbeiter, B002
- Angestellte; Lehrlinge B044/B045 und geringfügig/fallweise
- (B010/B030) ohne Default-Parameter → Lauf bricht ab, bis
- `at_mbgm_tarif` gepflegt ist (Tarifsystem-Werte, keine
- Erfindung)."""
+ """Beschäftigtengruppe (SV-Tarifsystem, TASY-Bezeichner):
+ B001 Arbeiter, B002 Angestellte; Lehrlinge B045
+ (Arbeiterlehrlinge) / B044 (Angestelltenlehrlinge);
+ geringfügig/fallweise B010 (Arbeiter) / B030 (Angestellte).
+ Ohne Default-Parameter → Lauf bricht ab, bis der Katalog
+ importiert oder ``at_mbgm_tarif`` gepflegt ist (Tarifsystem-
+ Werte, keine Erfindung)."""
+ personalgruppe = getattr(
+ version, 'l10n_at_personalgruppe', '')
if version.l10n_at_lehrling:
- return 'B044' if getattr(
- version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
- else 'B045'
+ return 'B045' if personalgruppe == 'arbeiter' else 'B044'
if slip.l10n_at_geringfuegig or version.l10n_at_fallweise:
- return 'B030'
- return 'B001' if getattr(
- version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
- else 'B002'
+ return 'B010' if personalgruppe == 'arbeiter' else 'B030'
+ return 'B001' if personalgruppe == 'arbeiter' else 'B002'
- def _l10n_at_tarif(self, bsgr):
+ def _l10n_at_tarif(self, bsgr, version=None):
+ """T01/T02-Gesamtsätze je BSGR — Katalog vor Hand-Parameter.
+
+ 1. TASY-Katalog: Tarifgruppe aus BSGR + ERGB am Vertrag +
+ Dienstgebergruppe der Company am Stichtag; Sätze = Summe
+ der Tarifset-Komponenten je Verrechnungsbasis (AB/SZ).
+ 2. Fallback: ``at_mbgm_tarif``-Parameter (BSGR-Schlüssel).
+
+ Rückgabe ``{'laufend', 'sz', 'quelle'}`` oder None → Lauf
+ bricht listenweise ab.
+ """
self.ensure_one()
+ ergaenzungen = version.l10n_at_ergaenzung_ids.mapped('code') \
+ if version is not None else []
+ dgg = self.company_id.l10n_at_dienstgebergruppe or 'NBALG'
+ tarifgruppe = self.env[
+ 'l10n.at.payroll.tasy.tarifgruppe']._l10n_at_find(
+ bsgr, ergaenzungen, dgg, self.beitragsmonat)
+ if tarifgruppe:
+ ab = tarifgruppe._l10n_at_saetze('AB', self.beitragsmonat)
+ sz = tarifgruppe._l10n_at_saetze('SZ', self.beitragsmonat)
+ if ab is None or sz is None:
+ return None
+ return {'laufend': ab['gesamt'], 'sz': sz['gesamt'],
+ 'quelle': 'katalog'}
tarif = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_mbgm_tarif', self.beitragsmonat,
raise_if_not_found=False) or {}
- return tarif.get(bsgr)
+ eintrag = tarif.get(bsgr)
+ if eintrag:
+ return {'laufend': eintrag['laufend'], 'sz': eintrag['sz'],
+ 'quelle': 'parameter'}
+ return None
def _l10n_at_positionen(self, slip, tarif):
"""Verrechnungspositionen je Tarifblock: T01 (Gesamtsatz,
@@ -202,7 +239,11 @@ class L10nAtPayrollMbgm(models.Model):
version = slip.version_id
ab = sum(basis for _m, basis
in slip._l10n_at_sv_basis_laufen_monate())
+ # NSchAB über das Company-Flag nur im Parameter-Fallback —
+ # im Katalog-Pfad steckt der NB-Zweig über die ERGB-E01-
+ # Tarifgruppe (Dienstgebergruppe NBNSA) bereits im T01.
nschab = 3.80 if (self.company_id.l10n_at_nschab
+ and tarif.get('quelle') != 'katalog'
and not version.l10n_at_lehrling
and getattr(version, 'l10n_at_personalgruppe',
'') == 'arbeiter') else 0.0
@@ -329,12 +370,12 @@ class L10nAtPayrollMbgm(models.Model):
anzahl += 1
continue
bsgr = deklaration._l10n_at_bsgr(version, slip)
- tarif = deklaration._l10n_at_tarif(bsgr)
+ tarif = deklaration._l10n_at_tarif(bsgr, version)
if not tarif:
fehler.append(
'Mitarbeiter %s: kein Tarifsatz für BSGR '
- '%s parametrisiert (at_mbgm_tarif, Werte '
- 'aus dem SV-Tarifsystem pflegen).'
+ '%s (weder TASY-Katalog noch Parameter '
+ 'at_mbgm_tarif am Beitragsmonat).'
% (employee.name, bsgr))
continue
if slip.l10n_at_geringfuegig \
@@ -442,6 +483,12 @@ class L10nAtPayrollMbgm(models.Model):
bezeichnung='Tarifblock %s' % employee.name)
tarifblock.setze('SART', 'T1')
tarifblock.setze('BSGR', bsgr)
+ # ERGB (DM-ORG D.49): bis zu 5 Ergänzungen à 3 Stellen —
+ # Grundstellung blank, wenn keine gepflegt (Katalog-
+ # Normalfall ohne Ergänzung).
+ ergaenzungen = version.l10n_at_ergaenzung_ids.mapped('code')[:5]
+ if ergaenzungen:
+ tarifblock.setze('ERGB', ''.join(ergaenzungen))
tarifblock.setze('VVON', '1')
saetze = [mbgm_satz, tarifblock]
for code, satz_prozent, rsum, basis_typ in positionen:
@@ -479,12 +526,39 @@ class ResCompany(models.Model):
_inherit = 'res.company'
l10n_at_nschab = fields.Boolean(
- string='AT Schlechtwetterentschädigungsbeitrag-Branche',
- help='Betrieb mit Schlechtwetterentschädigung (ÖGK-'
- 'Tarifsystem „Normalbetriebe mit '
- 'Schlechtwetterentschädigung“): Arbeiter (BSGR B001) '
- 'erhalten im mBGM-T01 den um NSchAB 3,80 % erhöhten '
- 'Gesamtsatz.')
+ string='AT Nachtschwerarbeitsbeitrags-Betrieb (mBGM-Fallback)',
+ help='Betrieb mit Nachtschwerarbeit (ÖGK-Tarifsystem '
+ '„Normalbetriebe mit Nachtschwerarbeit“, Dienstgebergruppe '
+ 'NBNSA): ohne TASY-Katalog erhalten Arbeiter (BSGR B001) '
+ 'im mBGM-T01 den um NSchAB 3,80 % erhöhten Gesamtsatz. '
+ 'Mit Katalog stattdessen die Ergänzung E01 am Vertrag '
+ 'setzen — der NB-Zweig ist dann Teil der E01-Tarifgruppe.')
+ l10n_at_dienstgebergruppe = fields.Char(
+ string='AT Dienstgebergruppe (SV-Tarifsystem)',
+ default='NBALG',
+ help='Code der Dienstgebergruppe des TASY-Tarifsystems, z. B. '
+ 'NBALG „Normalbetriebe — Allgemeiner Teil“, NBNSA '
+ '(Nachtschwerarbeit), NBBAU (Schlechtwetter), HAUSH '
+ '(Haushalte), DGONO (ohne Niederlassung). Bestimmt mit '
+ 'BSGR + Ergänzungen die Tarifgruppe der mBGM-Tarifierung '
+ '(AP0). Vollständige Liste: Tarifsystem-Katalog-Menü '
+ 'nach dem TASY-Import.')
+
+
+class HrVersion(models.Model):
+ _inherit = 'hr.version'
+
+ l10n_at_ergaenzung_ids = fields.Many2many(
+ 'l10n.at.payroll.tasy.ergaenzung',
+ 'l10n_at_hr_payroll_version_ergaenzung_rel',
+ 'version_id', 'ergaenzung_id',
+ string='AT Ergänzungen (ERGB, SV-Tarifsystem)',
+ groups="hr_payroll.group_hr_payroll_user",
+ help='Ergänzungen zur Beschäftigtengruppe (DM-ORG D.49) für die '
+ 'mBGM-Tarifierung — gemeinsam mit BSGR und Dienstgebergruppe '
+ 'bilden sie die Tarifgruppe. Z. B. E01 Nachtschwerarbeit, '
+ 'E02 Schlechtwetterentschädigung. Katalog nach dem TASY-'
+ 'Import; ohne Eintrag gilt der Normalfall ohne Ergänzung.')
class L10nAtPayrollMbgmLine(models.Model):
diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py
index efaaaff..087e704 100644
--- a/addons/l10n_at_hr_payroll_private/tests/__init__.py
+++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py
@@ -5,4 +5,5 @@ from . import test_lst_private
from . import test_sv_private
from . import test_bv_private
from . import test_account_private
-from . import test_meldewesen_private
\ No newline at end of file
+from . import test_meldewesen_private
+from . import test_reports_private
diff --git a/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py b/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py
index 456d4b0..cd5ba83 100644
--- a/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py
+++ b/addons/l10n_at_hr_payroll_private/tests/test_meldewesen_private.py
@@ -1,7 +1,8 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP7 privat: mBGM (Paket/mBGM/Tarifblock/Basis/Position-Hierarchie
-aus GP4-Beitragssummen, T01-Gesamtsatz aus at_mbgm_tarif, Abschläge
-A01/A02/A03 §-2a-AMPFG, A09/A10/A15, BV-Basis V01) und
+aus GP4-Beitragssummen, T01-Gesamtsatz aus TASY-Katalog vor
+at_mbgm_tarif, Abschläge A01/A02/A03 §-2a-AMPFG, A09/A10/A15,
+BV-Basis V01, BSGR-Lehrlinge/Geringfügige korrigiert, ERGB) und
Versichertenmeldung M3/M4 (deklarative Event-Erkennung,
Meldungs-Registry, AGRD)."""
import base64
@@ -10,6 +11,10 @@ import datetime
from odoo.exceptions import UserError
from odoo.tests import TransactionCase, tagged
+from odoo.addons.l10n_at_hr_payroll.tests.test_tasy_katalog import (
+ TASY_KATALOG_FIXTURE,
+)
+
@tagged('post_install', '-at_install')
class MeldewesenPrivateTest(TransactionCase):
@@ -165,10 +170,16 @@ class MeldewesenPrivateTest(TransactionCase):
})
with self.assertRaises(UserError):
zweiter.action_erzeugen()
+ # Ursache OHNE AGRD-Mapping → M4-Fallback 00 (D.22-Standard).
+ # (Suche-beliebig wäre nach dem AGRD-Seed nicht mehr deterministisch
+ # — der Seed verbindet die üblichen Ursachen vor.)
+ grund_ohne = self.env['hr.departure.reason'].create({
+ 'name': 'Testursache ohne AGRD',
+ 'country_id': self.env.ref('base.at').id,
+ })
self.version.write({
'contract_date_end': datetime.date(2026, 2, 15),
- 'departure_reason_id': self.env['hr.departure.reason']
- .search([], limit=1).id,
+ 'departure_reason_id': grund_ohne.id,
})
dritter = self.env['l10n.at.payroll.vr'].create({
'company_id': self.company.id,
@@ -176,8 +187,134 @@ class MeldewesenPrivateTest(TransactionCase):
})
dritter.action_erzeugen()
roh = base64.b64decode(dritter.datei).decode('ISO-8859-15')
+ zeilen = roh.strip('\r\n').split('\r\n')
+ m4 = [z for z in zeilen if z[0:2] == 'M4']
+ self.assertEqual(len(m4), 1)
+ self.assertEqual(m4[0][512:520], '15022026') # ADAT
+ self.assertEqual(m4[0][548:550], '00') # AGRD Default
+
+ def test_06_agrd_vorverbunden(self):
+ """Der AGRD-Seed verbindet die üblichen Abgangsursachen vor:
+ M4 der Pensionierung trägt Code 16 (D.22), hr-Defaults sind
+ gemappt (fired→01, resigned→02, retired→16)."""
+ self.assertEqual(self.env.ref('hr.departure_fired').l10n_at_agrd,
+ '01')
+ self.assertEqual(self.env.ref('hr.departure_resigned')
+ .l10n_at_agrd, '02')
+ employee = self.env['hr.employee'].create({
+ 'name': 'AGRD Test', 'birthday': '1985-03-20'})
+ version = self.env['hr.version'].search(
+ [('employee_id', '=', employee.id)], limit=1)
+ version.write({
+ 'contract_date_start': datetime.date(2020, 1, 1),
+ 'contract_date_end': datetime.date(2026, 3, 31),
+ 'ssnid': '5678200385',
+ 'sex': 'male',
+ 'departure_reason_id': self.env.ref(
+ 'hr.departure_retired').id,
+ })
+ lauf = self.env['l10n.at.payroll.vr'].create({
+ 'company_id': self.company.id,
+ 'stichtag': datetime.date(2026, 4, 30),
+ })
+ lauf.action_erzeugen()
+ roh = base64.b64decode(lauf.datei).decode('ISO-8859-15')
m4 = [z for z in roh.strip('\r\n').split('\r\n')
if z[0:2] == 'M4']
self.assertEqual(len(m4), 1)
- self.assertEqual(m4[0][512:520], '15022026') # ADAT
- self.assertEqual(m4[0][548:550], '00') # AGRD Default
\ No newline at end of file
+ self.assertEqual(m4[0][548:550], '16') # AGRD Pensionierung
+
+ def _katalog_importieren(self):
+ wizard = self.env['l10n.at.payroll.tasy.katalog.wizard'].create({
+ 'file': base64.b64encode(TASY_KATALOG_FIXTURE.encode())
+ .decode(),
+ 'filename': 'fixture.xml',
+ })
+ wizard.action_import()
+
+ def test_03_bsgr_mapping(self):
+ """BSGR nach TASY-Bezeichnern: Lehrling Arbeiter→B045,
+ Lehrling Angestellte→B044, fallweise/gfg Arbeiter→B010,
+ Angestellte→B030, Normalfall B001/B002."""
+ deklaration = self.env['l10n.at.payroll.mbgm'].new({
+ 'company_id': self.company.id,
+ 'beitragsmonat': datetime.date(2026, 11, 1),
+ })
+ slip = self.env['hr.payslip'].new({})
+ # Normalfall
+ self.assertEqual(
+ deklaration._l10n_at_bsgr(self.version, slip), 'B002')
+ self.version.l10n_at_personalgruppe = 'arbeiter'
+ self.assertEqual(
+ deklaration._l10n_at_bsgr(self.version, slip), 'B001')
+ # Lehrlinge — korrigierte Zuordnung (B045 = Arbeiterlehrlinge)
+ self.version.l10n_at_lehrling = True
+ self.assertEqual(
+ deklaration._l10n_at_bsgr(self.version, slip), 'B045')
+ self.version.l10n_at_personalgruppe = 'angestellte'
+ self.assertEqual(
+ deklaration._l10n_at_bsgr(self.version, slip), 'B044')
+ self.version.l10n_at_lehrling = False
+ # Geringfügige/fallweise je Personalgruppe
+ self.version.l10n_at_fallweise = True
+ self.assertEqual(
+ deklaration._l10n_at_bsgr(self.version, slip), 'B030')
+ self.version.l10n_at_personalgruppe = 'arbeiter'
+ self.assertEqual(
+ deklaration._l10n_at_bsgr(self.version, slip), 'B010')
+
+ def test_04_mbgm_katalog_vor_parameter(self):
+ """Katalog schlägt den Hand-Parameter: B002-T01 bleibt 39,05 %
+ (39050), selbst wenn at_mbgm_tarif einen abweichenden Satz
+ trägt."""
+ self._katalog_importieren()
+ parameter = self.env['hr.rule.parameter'].search([
+ ('code', '=', 'at_mbgm_tarif')])
+ parameter.parameter_version_ids.write(
+ {'parameter_value':
+ "{'B001': {'laufend': 11.0, 'sz': 11.0}, "
+ "'B002': {'laufend': 11.0, 'sz': 11.0}}"})
+ slip = self._slip(11)
+ deklaration = self.env['l10n.at.payroll.mbgm'].create({
+ 'company_id': self.company.id,
+ 'beitragsmonat': datetime.date(2026, 11, 1),
+ 'art': 'MB',
+ })
+ deklaration.action_erzeugen()
+ self.assertEqual(deklaration.state, 'done')
+ roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
+ zeilen = roh.strip('\r\n').split('\r\n')
+ daten = zeilen[2:-1]
+ v1_t01 = daten[4]
+ self.assertEqual(v1_t01[20:23], 'T01')
+ self.assertEqual(v1_t01[24:30], '039050') # Katalog 39,05 %
+ v1_t02 = daten[8]
+ self.assertEqual(v1_t02[24:30], '037550') # Katalog 37,55 %
+ # ERGB Grundstellung: blank (keine Ergänzung gepflegt)
+ t1 = daten[2]
+ self.assertEqual(t1[24:39], ' ' * 15)
+
+ def test_05_mbgm_ergb_e01(self):
+ """Ergänzung E01 + Dienstgebergruppe NBNSA: T01 enthält den
+ NB-Zweig (42,85 % → 042850), ERGB-Feld trägt E01."""
+ self._katalog_importieren()
+ e01 = self.env['l10n.at.payroll.tasy.ergaenzung'].search([
+ ('code', '=', 'E01')])
+ self.company.l10n_at_dienstgebergruppe = 'NBNSA'
+ self.version.l10n_at_ergaenzung_ids = [(6, 0, e01.ids)]
+ self._slip(11)
+ deklaration = self.env['l10n.at.payroll.mbgm'].create({
+ 'company_id': self.company.id,
+ 'beitragsmonat': datetime.date(2026, 11, 1),
+ 'art': 'MB',
+ })
+ deklaration.action_erzeugen()
+ self.assertEqual(deklaration.state, 'done')
+ roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
+ zeilen = roh.strip('\r\n').split('\r\n')
+ daten = zeilen[2:-1]
+ t1 = daten[2]
+ self.assertEqual(t1[24:39], 'E01' + ' ' * 12) # ERGB
+ v1_t01 = daten[4]
+ self.assertEqual(v1_t01[20:23], 'T01')
+ self.assertEqual(v1_t01[24:30], '042850') # 42,85 % inkl. NB
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/tests/test_reports_private.py b/addons/l10n_at_hr_payroll_private/tests/test_reports_private.py
new file mode 100644
index 0000000..a063e03
--- /dev/null
+++ b/addons/l10n_at_hr_payroll_private/tests/test_reports_private.py
@@ -0,0 +1,242 @@
+# Part of the odoo-at-payroll project. License: LGPL-3.
+"""AP5 Abwesenheiten (hr_holidays-Anbindung, AT-WETs) und AP2a-c
+Reports: Dienstgeber-Gesamtkosten, Monatsjournal-DG-Block,
+Jahreslohnkonto je DN, Betriebsjahreslohnkonto."""
+import base64
+import datetime
+
+from odoo import SUPERUSER_ID
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged('post_install', '-at_install')
+class ReportsPrivateTest(TransactionCase):
+
+ def setUp(self):
+ super().setUp()
+ self.company = self.env.company
+ self.company.write({
+ '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 Reports 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': 'Reports Test'})
+ version = self.env['hr.version'].search(
+ [('employee_id', '=', employee.id)], limit=1)
+ version.write({
+ 'contract_date_start': datetime.date(2020, 1, 1),
+ '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': 'Reports %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 _run(self, monat, jahr=2026):
+ """Lohnlauf mit einem bestätigten Slip des Monats."""
+ slip = self._slip(monat, jahr)
+ run = self.env['hr.payslip.run'].create({
+ 'name': 'Reports-Lauf %02d/%d' % (monat, jahr),
+ 'date_start': datetime.date(jahr, monat, 1),
+ 'date_end': (datetime.date(jahr, monat, 1)
+ .replace(day=28)
+ + datetime.timedelta(days=4)).replace(day=1)
+ - datetime.timedelta(days=1),
+ })
+ slip.payslip_run_id = run.id
+ return run, slip
+
+ def _total(self, slip, predicate):
+ return sum(slip.line_ids.filtered(predicate).mapped('total'))
+
+ # ------------------------------------------------------------------
+ # AP5: AT-Abwesenheiten
+ # ------------------------------------------------------------------
+ def test_01_absenzen_wets_und_leave_typen(self):
+ """AT-WETs (Urlaub/Krankenstand/Karenz) sind is_leave und an
+ die country-gebundenen Urlaubsarten verknüpft; ein validierter
+ Krankenstand erzeugt Work Entries mit dem AT-WET."""
+ wets = self.env['hr.work.entry.type'].search([
+ ('country_id.code', '=', 'AT'), ('is_leave', '=', True)])
+ codes = set(wets.mapped('code'))
+ self.assertTrue({'ATP_URLAUB', 'ATP_KRANK', 'ATP_KARENZ'}
+ <= codes, codes)
+ urlaub_typ = self.env.ref(
+ 'l10n_at_hr_payroll_private.leave_type_at_urlaub')
+ krankenstand_typ = self.env.ref(
+ 'l10n_at_hr_payroll_private.leave_type_at_krankenstand')
+ self.assertEqual(urlaub_typ.work_entry_type_id.code, 'ATP_URLAUB')
+ self.assertTrue(urlaub_typ.requires_allocation)
+ self.assertEqual(krankenstand_typ.work_entry_type_id.code,
+ 'ATP_KRANK')
+ self.assertTrue(krankenstand_typ.support_document)
+ # Validierter Krankenstand → Work Entry mit AT-WET (Mo, 2.11.2026).
+ # Work Entries müssen für den Zeitraum bereits generiert sein
+ # (hr_work_entry_holidays erzeugt Leave-Einträge nur „in bereits
+ # generierte Zeiträume“ — _cancel_work_entry_conflict).
+ self.version.generate_work_entries(
+ datetime.date(2026, 11, 1), datetime.date(2026, 11, 30))
+ leave = self.env['hr.leave'].create({
+ 'name': 'Krankenstand Test',
+ 'employee_id': self.employee.id,
+ 'holiday_status_id': krankenstand_typ.id,
+ 'request_date_from': datetime.date(2026, 11, 2),
+ 'request_date_to': datetime.date(2026, 11, 2),
+ })
+ leave.with_user(SUPERUSER_ID)._action_validate()
+ work_entries = self.env['hr.work.entry'].search([
+ ('leave_id', '=', leave.id)])
+ self.assertTrue(work_entries)
+ self.assertTrue(all(
+ w.work_entry_type_id.code == 'ATP_KRANK'
+ for w in work_entries))
+
+ # ------------------------------------------------------------------
+ # AP2a: Dienstgeber-Gesamtkosten
+ # ------------------------------------------------------------------
+ def test_02_dg_kosten_report(self):
+ """Je AN: Brutto, aufgeschlüsselte LNK (SV-DG, FLAF, KommSt,
+ BV) und kumulierte Gesamtkosten = Brutto + Σ LNK."""
+ run, slip = self._run(11)
+ werte = self.env[
+ 'report.l10n_at_hr_payroll.report_dg_kosten'
+ ]._get_report_values(run.ids)
+ self.assertEqual(len(werte['zeilen']), 1)
+ zeile = werte['zeilen'][0]
+ brutto = self._total(
+ slip, lambda l: l.salary_rule_id.category_id.code
+ == 'ATP_BRUTTO'
+ and l.salary_rule_id.l10n_at_bemessung != 'neutral')
+ dg = self._total(
+ slip, lambda l: l.salary_rule_id.category_id.code
+ == 'ATP_DG')
+ self.assertAlmostEqual(zeile['brutto'], brutto, places=2)
+ self.assertAlmostEqual(zeile['dg_summe'], dg, places=2)
+ self.assertAlmostEqual(zeile['gesamtkosten'],
+ brutto + dg, places=2)
+ self.assertAlmostEqual(zeile['spalten']['FLAFDB'],
+ self._total(slip, lambda l: l.code
+ == 'FLAFDB'), places=2)
+ self.assertAlmostEqual(zeile['spalten']['KOMMST'],
+ self._total(slip, lambda l: l.code
+ == 'KOMMST'), places=2)
+ self.assertAlmostEqual(zeile['spalten']['BVG'],
+ self._total(slip, lambda l: l.code
+ == 'BVG'), places=2)
+ self.assertAlmostEqual(zeile['svdg'],
+ slip._l10n_at_svdg_gesamt()
+ + sum(slip._l10n_at_svdg_sz(k) for k in
+ ('kv', 'pv', 'av', 'uv', 'iesg')),
+ places=2)
+ # Kumuliert über den Lauf
+ self.assertAlmostEqual(werte['summen']['gesamtkosten'],
+ zeile['gesamtkosten'], places=2)
+
+ def test_03_monatsjournal_dg_block(self):
+ """Monatsjournal führt die Dienstgeberanteile (ATP_DG) als
+ eigenen Block — SVDG_KV/FLAFDB/KOMMST/BVG erscheinen."""
+ run, slip = self._run(11)
+ werte = self.env[
+ 'report.l10n_at_hr_payroll.report_monatsjournal'
+ ]._get_report_values(run.ids)
+ eintrag = werte['journal'][0]
+ dg_codes = {art['code'] for art in eintrag['dg_arten']}
+ self.assertIn('SVDG_KV', dg_codes)
+ self.assertIn('FLAFDB', dg_codes)
+ self.assertIn('KOMMST', dg_codes)
+ self.assertIn('BVG', dg_codes)
+ self.assertAlmostEqual(
+ eintrag['summe_dg'],
+ self._total(slip, lambda l: l.salary_rule_id.category_id
+ .code == 'ATP_DG'), places=2)
+ # Bezüge/Abzüge ohne DG-Zeilen (Bemessungsfilter)
+ art_codes = {art['code'] for art in eintrag['arten']}
+ self.assertNotIn('SVDG_KV', art_codes)
+
+ # ------------------------------------------------------------------
+ # AP2b/2c: Jahreslohnkonto / Betriebsjahreslohnkonto
+ # ------------------------------------------------------------------
+ def test_04_jahreslohnkonto_je_dn(self):
+ """Monatsmatrix je Lohnart: KVGEO in November und Dezember,
+ Jahressumme = Summe beider Monate."""
+ slip_nov = self._slip(11)
+ slip_dez = self._slip(12)
+ wizard = self.env[
+ 'l10n.at.payroll.jahresbericht.wizard'
+ ].create({'jahr': 2026, 'art': 'dn',
+ 'employee_ids': [(6, 0, self.employee.ids)]})
+ zeilen = wizard._l10n_at_lohnkonto_zeilen(
+ 2026, self.company.id, self.employee.id)
+ kvgeo = next(z for z in zeilen if z['code'] == 'KVGEO')
+ total_soll = sum(
+ slip_nov.line_ids.filtered(lambda l: l.code == 'KVGEO')
+ .mapped('total')) + sum(
+ slip_dez.line_ids.filtered(lambda l: l.code == 'KVGEO')
+ .mapped('total'))
+ self.assertAlmostEqual(kvgeo['monate'][10],
+ sum(slip_nov.line_ids.filtered(
+ lambda l: l.code == 'KVGEO')
+ .mapped('total')), places=2)
+ self.assertAlmostEqual(kvgeo['monate'][11],
+ sum(slip_dez.line_ids.filtered(
+ lambda l: l.code == 'KVGEO')
+ .mapped('total')), places=2)
+ self.assertAlmostEqual(kvgeo['total'], total_soll, places=2)
+ # Report-Wrapper liefert denselben Datenstand je DN
+ werte = self.env[
+ 'report.l10n_at_hr_payroll.report_jahreslohnkonto'
+ ]._get_report_values(self.employee.ids, {'jahr': 2026})
+ self.assertEqual(len(werte['berichte']), 1)
+ self.assertEqual(werte['jahr'], 2026)
+
+ def test_05_betriebslohnkonto(self):
+ """Betriebsjahreslohnkonto: je Lohnart Betriebssummen (ohne
+ DN-Split), Monate konsolidiert."""
+ self._slip(11)
+ self._slip(12)
+ wizard = self.env[
+ 'l10n.at.payroll.jahresbericht.wizard'
+ ].create({'jahr': 2026, 'art': 'betrieb'})
+ zeilen = wizard._l10n_at_lohnkonto_zeilen(2026, self.company.id)
+ self.assertTrue(zeilen)
+ kvgeo = next(z for z in zeilen if z['code'] == 'KVGEO')
+ self.assertAlmostEqual(
+ kvgeo['total'], sum(kvgeo['monate']), places=2)
+ werte = self.env[
+ 'report.l10n_at_hr_payroll.report_betriebslohnkonto'
+ ]._get_report_values(self.company.ids, {'jahr': 2026})
+ self.assertEqual(len(werte['berichte']), 1)
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml b/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml
index 4b0b9bd..8994bd8 100644
--- a/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml
+++ b/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml
@@ -23,7 +23,7 @@
-
+
res.company.form.l10n.at.nschab
res.company
@@ -31,6 +31,8 @@
ref="l10n_at_hr_payroll.res_company_view_form_l10n_at"/>
+
diff --git a/addons/l10n_at_hr_payroll_private/views/vertragsfelder_views.xml b/addons/l10n_at_hr_payroll_private/views/vertragsfelder_views.xml
index 8fcd402..710746b 100644
--- a/addons/l10n_at_hr_payroll_private/views/vertragsfelder_views.xml
+++ b/addons/l10n_at_hr_payroll_private/views/vertragsfelder_views.xml
@@ -22,6 +22,9 @@
+