From 34e6750fe39f1336b626c2d0328aae9d2952884e Mon Sep 17 00:00:00 2001 From: fegger Date: Sat, 12 Sep 2026 23:17:38 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll_private: mBGM liest TASY-Katalog (BSGR/ERGB), AT-Abwesenheiten mit hr_holidays (AP0+AP1+AP5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AP0 — mBGM-Tarifierung aus dem TASY-Katalog: - Katalog vor Hand-Parameter at_mbgm_tarif (quelle katalog/parameter); Company-Feld l10n_at_dienstgebergruppe (Default NBALG), Vertrags-m2m l10n_at_ergaenzung_ids; ERGB wird im T1 real belegt - BSGR-Korrektur nach TASY-Bezeichnern: Lehrlinge Arbeiter->B045 / Angestellte->B044 (invertiert bisher), geringfuegig/fallweise B010/B030 je Personalgruppe - NSchAB-Feld: Falschbezeichnung Schlechtwetter -> Nachtschwerarbeit (3,80 % = NB-Zweig DG ab 2020); Flag nur noch Parameter-Fallback - Tests: Katalog schlaegt manipulierten Parameter, ERGB-Emission, BSGR-Mapping AP1 — M4-Test auf explizit ungemappte Abgangsursache (AGRD 00-Fallback) AP5 — hr_holidays/hr_work_entry_holidays als Pflicht-Dependence: - AT-WETs ATP_URLAUB/ATP_KRANK/ATP_KARENZ (is_leave) + AT-Urlaubsarten (UrlG, Krankenstand mit Krankmeldungspflicht, unbezahlte Karenz) nach Odoo-Lokalisierungsmuster; ATP_AU (Arbeitsunfall) folgt mit AP4 - Test: validierter Krankenstand erzeugt Work Entries mit AT-WET --- .../__manifest__.py | 6 +- .../data/absence_private.xml | 95 +++++++ .../l10n_at_hr_payroll_private/models/mbgm.py | 128 +++++++-- .../tests/__init__.py | 3 +- .../tests/test_meldewesen_private.py | 149 ++++++++++- .../tests/test_reports_private.py | 242 ++++++++++++++++++ .../views/mitarbeiter_views.xml | 4 +- .../views/vertragsfelder_views.xml | 3 + 8 files changed, 593 insertions(+), 37 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/absence_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_reports_private.py 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 @@ +