From 3c85cc679013a34c45ee7fbf68348d4f921d3660 Mon Sep 17 00:00:00 2001 From: fegger Date: Sat, 12 Sep 2026 23:17:00 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll: TASY-Tarifsystem-Katalog, AGRD-Auswahl, DG-Kosten-/Lohnkonten-Reports, Wr.-DAG-Grundlagen (AP0-AP3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AP0 Variante b — TASY-Tarifsystem-Katalog: - Neue Katalogmodelle (BSGR/ERGB/Dienstgebergruppen/Tarifgruppen/ Tarifsets/Tarife/Zuordnungen/Zu-Abschläge inkl. Steuerung), models/tasy_katalog.py — Auflösung Tarifgruppe (BSGR+ERGB+DGG) -> Sätze je Verrechnungsbasis - Import-Wizard l10n.at.payroll.tasy.katalog.wizard (alle Träger, sektionsscharf, idempotent), Views/Menüs, Security - Realexport-Test gegen TASY-LSWH-Export (B001/B002 = 39,05/37,55 %) AP1 — eSV-Abmeldegründe (DM-ORG D.22): - l10n_at_agrd Char -> vollständige Selection (33 Codes), Seed der Standard-Abgangsursachen (noupdate, hr-Defaults vorverbunden), Form-/Listen-Erbe AP2a-c — Auswertungen: - Monatsjournal: eigener Dienstgeberanteile-Block (ATP_DG vorher still ausgefiltert); Fix: models/monatsreports.py war nie in models/__init__.py importiert (GP7-Reports waren toter Code) - Neuer Report Dienstgeber-Gesamtkosten (Brutto + LNK aufgeschlüsselt/ kumuliert je AN + Laufsummen, Spalten FLAF/KommSt/DZ/BV/Wr. DAG) - Jahreslohnkonto je DN + Betriebsjahreslohnkonto (Jahresbericht- Wizard, Monatsmatrix je Lohnart), report/jahresreports.xml AP3 — Wr. Dienstgeberabgabe (RIS LrW GesNr 20000133 verifiziert): - Company-Flag l10n_at_wien_dag (Kern), Fristenarten wien_dag/ wien_dag_jahres (cron-geflaggt, 15./31. 3.), Company-View-Feld, Wr.-DAG-Spalte im DG-Kosten-Report --- addons/l10n_at_hr_payroll/__manifest__.py | 5 +- .../data/hr_departure_reason_seed.xml | 68 +++ addons/l10n_at_hr_payroll/models/__init__.py | 4 +- .../models/fristenkalender.py | 36 +- .../l10n_at_hr_payroll/models/meldewesen.py | 63 ++- .../models/monatsreports.py | 232 +++++++++- addons/l10n_at_hr_payroll/models/tasy.py | 424 +++++++++++++++++- .../l10n_at_hr_payroll/models/tasy_katalog.py | 300 +++++++++++++ .../report/belege_reports.xml | 101 ++++- .../report/jahresreports.xml | 138 ++++++ .../security/ir.model.access.csv | 22 +- addons/l10n_at_hr_payroll/tests/__init__.py | 1 + .../tests/test_tasy_katalog.py | 422 +++++++++++++++++ .../views/company_views.xml | 1 + .../views/meldewesen_views.xml | 22 + .../views/tasy_katalog_views.xml | 195 ++++++++ 16 files changed, 1999 insertions(+), 35 deletions(-) create mode 100644 addons/l10n_at_hr_payroll/data/hr_departure_reason_seed.xml create mode 100644 addons/l10n_at_hr_payroll/models/tasy_katalog.py create mode 100644 addons/l10n_at_hr_payroll/report/jahresreports.xml create mode 100644 addons/l10n_at_hr_payroll/tests/test_tasy_katalog.py create mode 100644 addons/l10n_at_hr_payroll/views/tasy_katalog_views.xml diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py index 6564f2c..f851410 100644 --- a/addons/l10n_at_hr_payroll/__manifest__.py +++ b/addons/l10n_at_hr_payroll/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)', - 'version': '19.0.5.1.0', + 'version': '19.0.6.0.0', 'author': 'ixsol', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -74,11 +74,14 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft 'data/rule_parameters_sv_2026.xml', 'data/rule_parameters_2027.xml', 'data/tasy_mapping.xml', + 'data/hr_departure_reason_seed.xml', 'views/tasy_views.xml', + 'views/tasy_katalog_views.xml', 'views/company_views.xml', 'views/meldewesen_views.xml', 'views/fristen_views.xml', 'report/belege_reports.xml', + 'report/jahresreports.xml', ], 'countries': ['at'], 'license': 'LGPL-3', diff --git a/addons/l10n_at_hr_payroll/data/hr_departure_reason_seed.xml b/addons/l10n_at_hr_payroll/data/hr_departure_reason_seed.xml new file mode 100644 index 0000000..93bfee8 --- /dev/null +++ b/addons/l10n_at_hr_payroll/data/hr_departure_reason_seed.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + 01 + + + 02 + + + 16 + + + + Einvernehmliche Lösung + + 40 + 03 + + + Zeitablauf + + 41 + 04 + + + Berechtigter vorzeitiger Austritt + + 42 + 05 + + + Fristlose Entlassung + + 43 + 06 + + + Karenz nach MSchG / VKG + + 44 + 07 + + + Tod des/der Dienstnehmers:in + + 45 + 13 + + + Sonstiger Grund mit Ende des Dienstverhältnisses + + 46 + 00 + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/__init__.py b/addons/l10n_at_hr_payroll/models/__init__.py index 02a6466..6a0e579 100644 --- a/addons/l10n_at_hr_payroll/models/__init__.py +++ b/addons/l10n_at_hr_payroll/models/__init__.py @@ -1,5 +1,6 @@ # Part of the odoo-at-payroll project. License: LGPL-3. from . import tasy +from . import tasy_katalog from . import lohnsteuer from . import sozialversicherung from . import elda @@ -8,4 +9,5 @@ from . import kind from . import lohnzettel_l16 from . import fristenkalender from . import jahresretro -from . import mitteilungsfile \ No newline at end of file +from . import mitteilungsfile +from . import monatsreports \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/fristenkalender.py b/addons/l10n_at_hr_payroll/models/fristenkalender.py index 0c81f97..1c9fa76 100644 --- a/addons/l10n_at_hr_payroll/models/fristenkalender.py +++ b/addons/l10n_at_hr_payroll/models/fristenkalender.py @@ -105,6 +105,32 @@ FRISTART_DEFINITIONEN = { 'ab 40 M / 35 F (⚠ blockiert — ASchV-Daten-' 'modell fehlt im Modul).', }, + 'wien_dag': { + 'name': 'Wr. Dienstgeberabgabe entrichten (Stadtkasse)', + 'monatlich': True, + 'tag': 15, + 'action': None, + 'wien_dag': True, + 'hilfetext': 'Wr. DAG („U-Bahn-Steuer“, GesNr 20000133): ' + 'Selbstberechnungsabgabe — die im Vormonat ' + 'entstandene Abgabenschuld bis zum 15. des ' + 'Folgemonats an die Stadtkasse (Magistrat) ' + 'entrichten (§ 6 Abs 1). Nur Firmen mit Flag ' + 'l10n_at_wien_dag (Beschäftigungsort Wien).', + }, + 'wien_dag_jahres': { + 'name': 'Wr. DAG-Jahreserklärung (Magistrat)', + 'monatlich': False, + 'monat': 3, + 'tag_im_monat': 31, + 'action': None, + 'wien_dag': True, + 'hilfetext': 'Bis 31. März die im Vorjahr entstandene ' + 'Abgabenschuld beim Magistrat schriftlich ' + 'erklären; darin auch die befreiten ' + 'Dienstverhältnisse angeben (§ 6 Abs 2 Wr. ' + 'DAG). Nur Firmen mit Flag l10n_at_wien_dag.', + }, } @@ -188,7 +214,10 @@ class L10nAtPayrollFrist(models.Model): deadline_monat = date( folgemonat.year, folgemonat.month, FRISTART_DEFINITIONEN['sv_mbgm']['tag']) - for art in ('sv_mbgm', 'db_dz_kommst'): + monatliche_arten = ['sv_mbgm', 'db_dz_kommst'] + if company.l10n_at_wien_dag: + monatliche_arten.append('wien_dag') + for art in monatliche_arten: self._sicherstellen( company, art, vormonat.year, vormonat.month, deadline_monat) @@ -209,6 +238,11 @@ class L10nAtPayrollFrist(models.Model): self._sicherstellen( company, 'kommst_jahres', heute.year - 1, None, date(heute.year, 3, 31)) + # Wr. DAG-Jahreserklärung (31. 3., § 6 Abs 2). + if company.l10n_at_wien_dag: + self._sicherstellen( + company, 'wien_dag_jahres', heute.year - 1, + None, date(heute.year, 3, 31)) @api.model def _sicherstellen(self, company, art, jahr, monat, deadline): diff --git a/addons/l10n_at_hr_payroll/models/meldewesen.py b/addons/l10n_at_hr_payroll/models/meldewesen.py index 91d209c..3763e12 100644 --- a/addons/l10n_at_hr_payroll/models/meldewesen.py +++ b/addons/l10n_at_hr_payroll/models/meldewesen.py @@ -15,13 +15,56 @@ Dienstgeberin und der Arbeitsstätten. 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).""" + DM-ORG D.22 — vollständige Code-Liste als Selection, Standard + ``00`` sonstiger Grund mit Ende des Beschäftigungsverhältnisses; + die üblichen Abgangsursachen sind im Seed vorverbunden, + noupdate — mandantenseitig ergänzte Ursachen bleiben unberührt).""" from odoo import api, fields, models from odoo.exceptions import ValidationError +# eSV-Abmeldegründe, DM-ORG 42.7.0 Kapitel D.22 (AGRD – Abmeldegrund, +# Code) — verifiziert gegen .firecrawl/ris/elda/DM-ORG_v42.7.0.txt +# (42. Ergänzung 07/2026). Codes 26/28 existieren nicht (Lücken im +# Katalog), Code 00 = sonstiger Grund mit Ende des DV. +L10N_AT_AGRD = [ + ('01', '01 — Kündigung durch den Dienstgeber'), + ('02', '02 — Kündigung durch den Dienstnehmer'), + ('03', '03 — Einvernehmliche Lösung'), + ('04', '04 — Zeitablauf'), + ('05', '05 — Berechtigter vorzeitiger Austritt'), + ('06', '06 — Fristlose Entlassung'), + ('07', '07 — Karenz nach MSchG / VKG'), + ('08', '08 — Präsenzdienstleistung im Bundesheer'), + ('09', '09 — Zivildienst'), + ('10', '10 — Pragmatisierung'), + ('11', '11 — Länger als 1 Monat währender unbezahlter Urlaub'), + ('12', '12 — Ummeldung'), + ('13', '13 — Tod des Dienstnehmers'), + ('14', '14 — Änderung der SV-Pflicht'), + ('15', '15 — Truppenübung'), + ('16', '16 — Pensionierung'), + ('17', '17 — Ende freier Dienstvertrag (§ 4 Abs 4 ASVG)'), + ('18', '18 — Enthebung von der Gerichtspraxis'), + ('19', '19 — Unterbrechung der Gerichtspraxis'), + ('20', '20 — Entlassung aus der Bundesbetreuung'), + ('21', '21 — Kündigung durch DN während Elternteilzeit (MSchG/VKG)'), + ('22', '22 — Unberechtigter vorzeitiger Austritt'), + ('23', '23 — Bildungskarenz gemäß § 11 AVRAG'), + ('24', '24 — Vorzeitiger Austritt gem. § 25 IO durch Dienstnehmer'), + ('25', '25 — Kündigung gem. § 25 IO durch Insolvenzverwalter:in'), + ('27', '27 — Kündigung gem. § 25 IO durch Dienstgeber'), + ('29', '29 — SV-Ende – Beschäftigung aufrecht'), + ('30', '30 — Lösung in der Probezeit durch Dienstgeber'), + ('31', '31 — Pflegekarenz (interner SV-Abmeldegrund)'), + ('32', '32 — Bildungskarenz gemäß § 12 AVRAG'), + ('33', '33 — Familienhospizkarenz (interner SV-Abmeldegrund)'), + ('34', '34 — Lösung in der Probezeit durch Dienstnehmer'), + ('00', '00 — Sonstiger Grund mit Ende des DV'), +] + + class ResCompany(models.Model): _inherit = 'res.company' @@ -36,6 +79,16 @@ class ResCompany(models.Model): help='9-stellig: 2 Stellen Finanzamtsnummer + 7 Stellen\n' 'Steuernummer (Lohnzettel Finanz I1 STNRA/STNRL;\n' 'Arbeitsstättenmeldung STNR).') + l10n_at_wien_dag = fields.Boolean( + string='AT Beschäftigungsort Wien (Wr. DAG)', + help='Wiener Dienstgeberabgabe („U-Bahn-Steuer“, Wr. DAG,\n' + 'RIS LrW GesNr 20000133): 2 € je Dienstnehmer:in und\n' + 'angefangene Woche je Bestehen eines Dienstverhältnisses\n' + 'mit Beschäftigungsort Wien (§§ 1, 2, 5); Befreiungen § 3\n' + '(z. B. Überschreiten des 55. Lebensjahres, Lehrlinge,\n' + '≤ 10 Wochenstunden, Mutterschaftsverbote/Karenz,\n' + 'Präsenzdienst, Gebietskörperschaften); Selbstberechnung\n' + '15. des Folgemonats, Jahreserklärung bis 31. 3. (§ 6).') class ResPartner(models.Model): @@ -89,8 +142,10 @@ class HrVersion(models.Model): class HrDepartureReason(models.Model): _inherit = 'hr.departure.reason' - l10n_at_agrd = fields.Char( + l10n_at_agrd = fields.Selection( + L10N_AT_AGRD, string='AT eSV-Abmeldegrund (AGRD)', help='2-stelliger Abmeldegrund-Code der Versichertenmeldung ' '(DM-ORG D.22); Standard 00 „Sonstiger Grund mit Ende des ' - 'Beschäftigungsverhältnisses“.') \ No newline at end of file + 'Beschäftigungsverhältnisses“. Die Abmeldung M4 liest das ' + 'Mapping dieser Abgangsursache.') \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/monatsreports.py b/addons/l10n_at_hr_payroll/models/monatsreports.py index 82bf43b..c6ab6ed 100644 --- a/addons/l10n_at_hr_payroll/models/monatsreports.py +++ b/addons/l10n_at_hr_payroll/models/monatsreports.py @@ -1,16 +1,29 @@ # 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. +"""Monatsreports und Jahresberichte der AT-Lohnverrechnung. -- **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 +- **Monatsdienstliste** (GP7/AP6): je AN die mBGM-Kontrollgrößen — + Abstimmung Abrechnung ↔ SV-Meldung. +- **Monatsjournal** (GP7/AP6, AP2a erweitert): je Lohnart die Summe + über den Lauf, gegliedert nach Bezügen/Abzügen (Bemessung ≠ + neutral) und — seit AP2a — **eigenem Block Dienstgeberanteile** + (Kategorie ``ATP_DG``: SV-DG je Zweig, FLAF-DB, KommSt, DZ, BV — + zuvor wurden die neutralen DG-Regeln still ausgefiltert) mit + Kontrollsummen gegen die GP6-Buchung. +- **Dienstgeber-Gesamtkosten** (AP2a): je AN Bruttolohn und die + Lohnnebenkosten aufgeschlüsselt (SV-DG laufend/SZ, FLAF-DB, + KommSt, DZ, BV, weitere DG-Positionen) und kumuliert + (Σ Lohnnebenkosten, Gesamtkosten = Brutto + LNK) mit Laufsummen. +- **Jahreslohnkonto je Dienstnehmer:in** (AP2b) und + **Betriebsjahreslohnkonto** (AP2c): Monatsmatrix je Lohnart aus + ``hr.payslip.line`` — Datenbereitstellung über den Wizard + ``l10n.at.payroll.jahresbericht.wizard``. + +Die Templates bleiben reine Darstellung (QWeb); alle Zahlen werden +hier vorberechnet (read_group-Muster des Contribution Register). +""" +from datetime import date + +from odoo import api, fields, models class L10nAtPayrollMonatsReports(models.AbstractModel): @@ -61,24 +74,32 @@ class L10nAtPayrollMonatsReports(models.AbstractModel): class L10nAtPayrollMonatsjournal(models.AbstractModel): _name = 'report.l10n_at_hr_payroll.report_monatsjournal' - _description = 'Monatsjournal (GP7/AP6)' + _description = 'Monatsjournal (GP7/AP6, AP2a)' def _get_report_values(self, docids, data=None): runs = self.env['hr.payslip.run'].browse(docids) zeilen = [] for run in runs: gruppen = {} + dg_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': + if not line.total: continue + kategorie = regel.category_id.code or '' schluessel = (regel.code, regel.name, regel.l10n_at_bemessung) - eintrag = gruppen.setdefault(schluessel, 0.0) - gruppen[schluessel] = eintrag + line.total + if kategorie == 'ATP_DG': + # AP2a: Dienstgeberanteile eigener Block — + # die neutralen DG-Regeln (SVDG_*, FLAFDB, + # KOMMST, DZ, BVG) nicht mehr ausfiltern. + dg_gruppen[schluessel] = \ + dg_gruppen.get(schluessel, 0.0) + line.total + elif regel.l10n_at_bemessung != 'neutral': + gruppen[schluessel] = \ + gruppen.get(schluessel, 0.0) + line.total zeilen.append({ 'run': run, 'arten': [ @@ -86,5 +107,182 @@ class L10nAtPayrollMonatsjournal(models.AbstractModel): 'summe': summe} for (code, name, bemessung), summe in sorted(gruppen.items())], + 'dg_arten': [ + {'code': code, 'name': name, 'bemessung': bemessung, + 'summe': summe} + for (code, name, bemessung), summe + in sorted(dg_gruppen.items())], + 'summe_arten': sum(gruppen.values()), + 'summe_dg': sum(dg_gruppen.values()), }) - return {'docs': runs, 'journal': zeilen} \ No newline at end of file + return {'docs': runs, 'journal': zeilen} + + +class L10nAtPayrollDgKosten(models.AbstractModel): + _name = 'report.l10n_at_hr_payroll.report_dg_kosten' + _description = 'Dienstgeber-Gesamtkosten (AP2a)' + + # Festspalten des Reports; weitere DG-Positionen (z. B. §-53a-UV + # der Geringfügigen) laufen unter 'weitere'. + _SPALTEN = ('FLAFDB', 'KOMMST', 'DZ', 'BVG', 'WIEN_DAG') + + def _get_report_values(self, docids, data=None): + runs = self.env['hr.payslip.run'].browse(docids) + zeilen = [] + summen = { + 'brutto': 0.0, 'svdg': 0.0, 'dg_summe': 0.0, + 'gesamtkosten': 0.0, + 'spalten': {code: 0.0 for code in self._SPALTEN}, + 'weitere': 0.0, + } + for run in runs: + for slip in run.slip_ids.filtered( + lambda p: p.state not in ('draft', 'cancel')): + brutto = sum( + slip.line_ids.filtered( + lambda l: l.total + and l.salary_rule_id.category_id.code + == 'ATP_BRUTTO' + and l.salary_rule_id.l10n_at_bemessung != 'neutral' + ).mapped('total')) + dg_komponenten = {} + for line in slip.line_ids: + regel = line.salary_rule_id + if not line.total \ + or regel.category_id.code != 'ATP_DG': + continue + dg_komponenten[regel.code] = \ + dg_komponenten.get(regel.code, 0.0) + line.total + svdg = (slip._l10n_at_svdg_gesamt() + + sum(slip._l10n_at_svdg_sz(k) for k in + ('kv', 'pv', 'av', 'uv', 'iesg'))) + spalten = {code: dg_komponenten.get(code, 0.0) + for code in self._SPALTEN} + dg_summe = sum(dg_komponenten.values()) + weitere = dg_summe - svdg - sum(spalten.values()) + zeilen.append({ + 'slip': slip, + 'brutto': brutto, + 'svdg': svdg, + 'spalten': spalten, + 'weitere': max(weitere, 0.0), + 'dg_summe': dg_summe, + 'gesamtkosten': brutto + dg_summe, + }) + summen['brutto'] += brutto + summen['svdg'] += svdg + summen['dg_summe'] += dg_summe + summen['gesamtkosten'] += brutto + dg_summe + summen['weitere'] += max(weitere, 0.0) + for code in self._SPALTEN: + summen['spalten'][code] += spalten[code] + return {'docs': runs, 'zeilen': zeilen, 'summen': summen} + + +class L10nAtPayrollJahresberichtWizard(models.TransientModel): + """Druck-Wrapper für Jahreslohnkonto (je DN) und + Betriebsjahreslohnkonto (Summen je Lohnart) — AP2b/AP2c.""" + _name = 'l10n.at.payroll.jahresbericht.wizard' + _description = 'Jahresberichte: Lohnkonten drucken' + + jahr = fields.Integer( + required=True, + default=lambda self: fields.Date.context_today(self).year) + art = fields.Selection( + [('dn', 'Jahreslohnkonto je Dienstnehmer:in'), + ('betrieb', 'Betriebsjahreslohnkonto (Summen je Lohnart)')], + required=True, default='dn') + company_id = fields.Many2one( + 'res.company', required=True, + default=lambda self: self.env.company) + employee_ids = fields.Many2many( + 'hr.employee', string='Dienstnehmer:innen', + help='Leer = alle Dienstnehmer:innen des Unternehmens.') + + def action_drucken(self): + self.ensure_one() + if self.art == 'dn': + employees = self.employee_ids or self.env[ + 'hr.employee'].search( + [('company_id', '=', self.company_id.id)]) + report = self.env.ref( + 'l10n_at_hr_payroll.action_report_jahreslohnkonto') + return report.report_action( + employees, data={'jahr': self.jahr}) + report = self.env.ref( + 'l10n_at_hr_payroll.action_report_betriebslohnkonto') + return report.report_action( + self.company_id, data={'jahr': self.jahr}) + + @api.model + def _l10n_at_lohnkonto_zeilen(self, jahr, company_id, + employee_id=None): + """Aggregierte Lohnartzeilen des Jahres: [(code, name, + bemessung, kategorie, [Jan..Dez], total), …] — sortiert nach + Kategorie und Code.""" + domain = [ + ('company_id', '=', company_id), + ('date_from', '>=', date(jahr, 1, 1)), + ('date_from', '<=', date(jahr, 12, 31)), + ('slip_id.state', 'not in', ('draft', 'cancel')), + ] + if employee_id: + domain.append(('employee_id', '=', employee_id)) + zeilen = {} + for line in self.env['hr.payslip.line'].search(domain): + regel = line.salary_rule_id + schluessel = (regel.code, regel.name, + regel.l10n_at_bemessung, + regel.category_id.code or '') + eintrag = zeilen.setdefault( + schluessel, {'monate': [0.0] * 12, 'total': 0.0}) + eintrag['monate'][line.date_from.month - 1] += line.total + eintrag['total'] += line.total + result = [] + for (code, name, bemessung, kategorie), eintrag in sorted( + zeilen.items()): + result.append({ + 'code': code, 'name': name, 'bemessung': bemessung, + 'kategorie': kategorie, + 'monate': eintrag['monate'], 'total': eintrag['total'], + }) + return result + + +class L10nAtPayrollJahreslohnkonto(models.AbstractModel): + _name = 'report.l10n_at_hr_payroll.report_jahreslohnkonto' + _description = 'Jahreslohnkonto je Dienstnehmer:in (AP2b)' + + def _get_report_values(self, docids, data=None): + jahr = (data or {}).get('jahr') \ + or fields.Date.context_today(self).year + employees = self.env['hr.employee'].browse(docids) + berichte = [] + for employee in employees: + berichte.append({ + 'employee': employee, + 'zeilen': self.env[ + 'l10n.at.payroll.jahresbericht.wizard' + ]._l10n_at_lohnkonto_zeilen( + jahr, employee.company_id.id, employee.id), + }) + return {'docs': employees, 'jahr': jahr, 'berichte': berichte} + + +class L10nAtPayrollBetriebslohnkonto(models.AbstractModel): + _name = 'report.l10n_at_hr_payroll.report_betriebslohnkonto' + _description = 'Betriebsjahreslohnkonto (AP2c)' + + def _get_report_values(self, docids, data=None): + jahr = (data or {}).get('jahr') \ + or fields.Date.context_today(self).year + companies = self.env['res.company'].browse(docids) + berichte = [] + for company in companies: + berichte.append({ + 'company': company, + 'zeilen': self.env[ + 'l10n.at.payroll.jahresbericht.wizard' + ]._l10n_at_lohnkonto_zeilen(jahr, company.id), + }) + return {'docs': companies, 'jahr': jahr, 'berichte': berichte} \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/tasy.py b/addons/l10n_at_hr_payroll/models/tasy.py index 2dc713a..e76dce1 100644 --- a/addons/l10n_at_hr_payroll/models/tasy.py +++ b/addons/l10n_at_hr_payroll/models/tasy.py @@ -1,4 +1,17 @@ # Part of the odoo-at-payroll project. License: LGPL-3. +"""TASY-LSWH-Import (SV-Tarifsystem, sozialversicherung.at/tarifsystem). + +Zwei Stufen: + +1. ``l10n.at.payroll.tasy.import.wizard`` — SV-Werte als + hr.rule.parameter (Mapping-gesteuert, Vorschau, historische + Werte bleiben erhalten; GP4). +2. ``l10n.at.payroll.tasy.katalog.wizard`` — Vollständiger + Tarifsystem-Katalog (AP0, Variante b): BSGR/ERGB/Dienstgeber- + gruppen/Tarifgruppen/Tarifsets/Tarife/Zu-/Abschläge, alle + Träger, versioniert über von/bis. Die mBGM-Tarifierung liest + daraus; der Hand-Parameter ``at_mbgm_tarif`` bleibt Fallback. +""" from lxml import etree import base64 @@ -7,6 +20,19 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError +def _l10n_at_tasy_root(file_field): + """TASY-XML robust laden: rohe XML-Bytes ODER base64.""" + parser = etree.XMLParser(resolve_entities=False, no_network=True) + data = file_field or b'' + if isinstance(data, str): + data = data.encode() + # Binary kann XML-Bytes ODER base64 (str/Bytes) liefern: + # dekodieren, wenn kein '<' am Anfang steht. + if not data.lstrip().startswith(b'<'): + data = base64.b64decode(data) + return etree.fromstring(data, parser) + + class L10nAtPayrollTasyMapping(models.Model): """Mapping TASY-Code/-Zweig -> SV-Regelparameter (hr.rule.parameter). @@ -102,15 +128,7 @@ class L10nAtPayrollTasyImportWizard(models.TransientModel): def _tasy_root(self): self.ensure_one() - parser = etree.XMLParser(resolve_entities=False, no_network=True) - data = self.file or b'' - if isinstance(data, str): - data = data.encode() - # Binary kann XML-Bytes ODER base64 (str/Bytes) liefern: - # dekodieren, wenn kein '<' am Anfang steht. - if not data.lstrip().startswith(b'<'): - data = base64.b64decode(data) - return etree.fromstring(data, parser) + return _l10n_at_tasy_root(self.file) @staticmethod def _tasy_text(node, tag): @@ -299,4 +317,392 @@ class L10nAtPayrollTasyImportWizard(models.TransientModel): count=applied), 'next': {'type': 'ir.actions.act_window_close'}, }, + } + + +class L10nAtPayrollTasyKatalogWizard(models.TransientModel): + """Lädt den vollständigen Tarifsystem-Katalog aus dem + TASY-LSWH-Export (AP0, Variante b) — alle Trägergruppen, + versioniert über von/bis-Datum. Idempotent: wiederholter Import + derselben Datei aktualisiert statt zu duplizieren; historische + Gültigkeitsfenster bleiben erhalten (Jahreswechsel = neuer + Import, keine Löschung). + + Die Sätze selbst werden NICHT hieraus in hr.rule.parameter + geschrieben — die mBGM-Tarifierung liest den Katalog direkt + (models/tasy_katalog.py); nur die Zweig-Split-Werte für den + GP4-Kern laufen über den Wert-Import-Wizard. + """ + _name = 'l10n.at.payroll.tasy.katalog.wizard' + _description = 'SV-Tarifsystem-Katalog importieren (TASY)' + + file = fields.Binary(required=True, string='TASY-Export (XML)') + filename = fields.Char() + stichtag = fields.Date( + readonly=True, + help="Gültigkeitstichtag des Exports (nur Dokumentation — " + "die Katalogzeilen tragen eigene von/bis-Fenster).") + protocol = fields.Text(readonly=True) + + @staticmethod + def _tasy_text(node, tag): + child = node.find(tag) + return (child.text or '').strip() if child is not None else '' + + @staticmethod + def _tasy_texts(node, tag): + """Alle Kindelemente dieses Tags — TASY nutzt wiederholte + Referenz-Elemente (z. B. xmlId).""" + return [child.text.strip() for child in node.findall(tag) + if child.text and child.text.strip()] + + @staticmethod + def _tasy_bool(text): + return (text or '').strip().lower() in ('true', '1', 'j', 'ja', + 'yes', 'y') + + def _tasy_root(self): + self.ensure_one() + return _l10n_at_tasy_root(self.file) + + @staticmethod + def _section(root, tag): + """Sektionsscharfe Iteration — root.iter() erfasste auch die + Referenz-Elemente (z. B. xmlId in + Zuordnungszeilen); die Sektionen enthalten nur echte Zeilen.""" + section = root.find(tag) + return list(section) if section is not None else [] + + # ------------------------------------------------------------------ + # Upsert-Helfer + # ------------------------------------------------------------------ + + def _upsert_code_katalog(self, model, nodes, protocol, label, + extra_vals=None): + """Katalogzeilen mit (Trägergruppe, Code, von) als Schlüssel.""" + created = updated = 0 + for node in nodes: + code = self._tasy_text(node, 'code') + von = self._tasy_text(node, 'vonDatum') + if not code or not von: + continue + vals = { + 'code': code, + 'bezeichnung': self._tasy_text(node, 'bezeichnung'), + 'kurzbezeichnung': self._tasy_text( + node, 'kurzbezeichnung'), + 'beschreibung': self._tasy_text(node, 'beschreibung'), + 'traegergruppe': self._tasy_text(node, 'traegergruppe'), + 'von_datum': fields.Date.to_date(von), + 'bis_datum': fields.Date.to_date( + self._tasy_text(node, 'bisDatum') or '2999-12-31'), + 'tasy_xml_id': node.get('xmlId'), + } + vals.update(extra_vals(node) if extra_vals else {}) + rec = self.env[model].search([ + ('traegergruppe', '=', vals['traegergruppe']), + ('code', '=', code), + ('von_datum', '=', vals['von_datum'])], limit=1) + if rec: + rec.write(vals) + updated += 1 + else: + self.env[model].create(vals) + created += 1 + protocol.append('%s: %d neu, %d aktualisiert' + % (label, created, updated)) + + def _upsert_tarif(self, nodes, protocol): + """Tarife ohne Code — Identität ist der Inhalt (Zweig, Sätze, + Gültigkeit); xmlId zuerst, dann Inhaltsvergleich.""" + created = updated = 0 + for node in nodes: + zweig = self._tasy_text(node, 'tarifZweig') + von = self._tasy_text(node, 'vonDatum') + if not zweig or not von: + continue + vals = { + 'zweig': zweig, + 'dn': float(self._tasy_text( + node, 'dienstnehmerAnteil') or 0.0), + 'dg': float(self._tasy_text( + node, 'dienstgeberAnteil') or 0.0), + 'traegergruppe': self._tasy_text(node, 'traegergruppe'), + 'von_datum': fields.Date.to_date(von), + 'bis_datum': fields.Date.to_date( + self._tasy_text(node, 'bisDatum') or '2999-12-31'), + 'tasy_xml_id': node.get('xmlId'), + } + rec = self.env['l10n.at.payroll.tasy.tarif'].search([ + ('zweig', '=', zweig), + ('traegergruppe', '=', vals['traegergruppe']), + ('von_datum', '=', vals['von_datum'])]).filtered( + lambda t, v=vals: abs(t.dn - v['dn']) < 1e-9 + and abs(t.dg - v['dg']) < 1e-9)[:1] + if rec: + rec.write(vals) + updated += 1 + else: + self.env['l10n.at.payroll.tasy.tarif'].create(vals) + created += 1 + protocol.append('Tarife: %d neu, %d aktualisiert' + % (created, updated)) + + def _upsert_tarifset(self, nodes, tarif_map, protocol): + """Tarifsets — Schlüssel (Träger, Kurzbezeichnung, von); + Tarif-Verknüpfungen werden je Import neu gesetzt.""" + created = updated = 0 + for node in nodes: + von = self._tasy_text(node, 'vonDatum') + kurz = self._tasy_text(node, 'kurzbezeichnung') + if not von: + continue + vals = { + 'bezeichnung': self._tasy_text(node, 'bezeichnung'), + 'kurzbezeichnung': kurz, + 'traegergruppe': self._tasy_text(node, 'traegergruppe'), + 'von_datum': fields.Date.to_date(von), + 'bis_datum': fields.Date.to_date( + self._tasy_text(node, 'bisDatum') or '2999-12-31'), + 'tasy_xml_id': node.get('xmlId'), + } + rec = self.env['l10n.at.payroll.tasy.tarifset'].search([ + ('traegergruppe', '=', vals['traegergruppe']), + ('kurzbezeichnung', '=', kurz), + ('von_datum', '=', vals['von_datum'])], limit=1) + tarife = [tarif_map[ref] for ref in self._tasy_texts( + node, 'tarife') if ref in tarif_map] + vals['tarif_ids'] = [(6, 0, [t.id for t in tarife])] + if rec: + rec.write(vals) + updated += 1 + else: + rec = self.env['l10n.at.payroll.tasy.tarifset'].create( + vals) + created += 1 + protocol.append('Tarifsets: %d neu, %d aktualisiert' + % (created, updated)) + + # ------------------------------------------------------------------ + # Import + # ------------------------------------------------------------------ + + def action_import(self): + self.ensure_one() + root = self._tasy_root() + stichtag = (root.findtext('gueltigkeitstichtag') or '').strip() + if not stichtag: + raise UserError(_('Ungültiger TASY-Export: ' + 'Gültigkeitstichtag fehlt.')) + self.stichtag = fields.Date.to_date(stichtag) + protocol = ['Stichtag des Exports: %s' % stichtag] + + # 1) Code-Kataloge: BSGR, Ergänzungen, Dienstgebergruppen, + # Zu-/Abschläge (inkl. Steuerung). + self._upsert_code_katalog( + 'l10n.at.payroll.tasy.bsgr', + self._section(root, 'beschaeftigtengruppen'), protocol, + 'BSGR') + self._upsert_code_katalog( + 'l10n.at.payroll.tasy.ergaenzung', + self._section(root, 'ergaenzungen'), protocol, + 'Ergänzungen') + self._upsert_code_katalog( + 'l10n.at.payroll.tasy.dienstgebergruppe', + self._section(root, 'dienstgebergruppen'), protocol, + 'Dienstgebergruppen') + + bsgr_map = {b.tasy_xml_id: b for b in self.env[ + 'l10n.at.payroll.tasy.bsgr'].search([])} + erg_map = {e.tasy_xml_id: e for e in self.env[ + 'l10n.at.payroll.tasy.ergaenzung'].search([])} + dgg_map = {d.tasy_xml_id: d for d in self.env[ + 'l10n.at.payroll.tasy.dienstgebergruppe'].search([])} + + zuabschlag_map = {} + zuabschlag_nodes = self._section(root, 'zuAbschlaege') + self._upsert_code_katalog( + 'l10n.at.payroll.tasy.zuabschlag', + zuabschlag_nodes, protocol, 'Zu-/Abschläge', + extra_vals=lambda n: { + 'zuabschlagkz': self._tasy_text(n, 'zuabschlagkz'), + 'versicherungsrelevant': self._tasy_bool( + self._tasy_text(n, 'versicherungsRelevant')), + }) + for za in self.env['l10n.at.payroll.tasy.zuabschlag'].search([]): + zuabschlag_map[za.tasy_xml_id] = za + steuerung_created = 0 + steuerung_obj = self.env[ + 'l10n.at.payroll.tasy.zuabschlag.steuerung'] + for node in self._section(root, 'zuAbschlaegeSteuerung'): + zuabschlag = zuabschlag_map.get( + self._tasy_text(node, 'zuAbschlag')) + von = self._tasy_text(node, 'vonDatum') + if not zuabschlag or not von: + continue + vals = { + 'zuabschlag_id': zuabschlag.id, + 'altersgrenze_min_jahr': float( + self._tasy_text(node, 'altersgrenzeMinJahr') or 0.0), + 'altersgrenze_soll_jahr': float( + self._tasy_text(node, 'altersgrenzeSollJahr') or 0.0), + 'geboren_bis': fields.Date.to_date( + self._tasy_text(node, 'geborenBis')) + if self._tasy_text(node, 'geborenBis') else False, + 'geschlecht': self._tasy_text(node, 'geschlecht'), + 'traegergruppe': self._tasy_text(node, 'traegergruppe'), + 'von_datum': fields.Date.to_date(von), + 'bis_datum': fields.Date.to_date( + self._tasy_text(node, 'bisDatum') or '2999-12-31'), + 'tasy_xml_id': node.get('xmlId'), + } + if not steuerung_obj.search([ + ('tasy_xml_id', '=', vals['tasy_xml_id'])], limit=1): + steuerung_obj.create(vals) + steuerung_created += 1 + protocol.append('Zu-/Abschlag-Steuerungen: %d neu' + % steuerung_created) + + # 2) Tarife, dann Tarifgruppen (m2m auf die Kataloge), + # dann Tarifsets (m2m auf die Tarife). + self._upsert_tarif(self._section(root, 'tarife'), protocol) + tarif_map = {t.tasy_xml_id: t for t in self.env[ + 'l10n.at.payroll.tasy.tarif'].search([])} + + tarifgruppe_obj = self.env['l10n.at.payroll.tasy.tarifgruppe'] + created = updated = 0 + for node in self._section(root, 'tarifgruppen'): + code = self._tasy_text(node, 'code') + von = self._tasy_text(node, 'vonDatum') + if not code or not von: + continue + vals = { + 'code': code, + 'bezeichnung': self._tasy_text(node, 'bezeichnung'), + 'traegergruppe': self._tasy_text(node, 'traegergruppe'), + 'von_datum': fields.Date.to_date(von), + 'bis_datum': fields.Date.to_date( + self._tasy_text(node, 'bisDatum') or '2999-12-31'), + 'tasy_xml_id': node.get('xmlId'), + 'fallweise_zulaessig': self._tasy_bool( + self._tasy_text(node, + 'fallweiseBeschaeftigungZulaessig')), + 'bv_zulaessig': self._tasy_bool( + self._tasy_text(node, + 'betrieblicheVorsorgeZulaessig')), + } + rec = tarifgruppe_obj.search([ + ('traegergruppe', '=', vals['traegergruppe']), + ('code', '=', code), + ('von_datum', '=', vals['von_datum'])], limit=1) + if not rec: + rec = tarifgruppe_obj.create(vals) + created += 1 + else: + rec.write(vals) + updated += 1 + rec.write({ + 'bsgr_ids': [(6, 0, [ + bsgr_map[r].id for r in self._tasy_texts( + node, 'beschaeftigtengruppen') + if r in bsgr_map])], + 'ergaenzung_ids': [(6, 0, [ + erg_map[r].id for r in self._tasy_texts( + node, 'ergaenzungen') if r in erg_map])], + 'dienstgebergruppe_ids': [(6, 0, [ + dgg_map[r].id for r in self._tasy_texts( + node, 'dienstgeberGruppen') + if r in dgg_map])], + }) + protocol.append('Tarifgruppen: %d neu, %d aktualisiert' + % (created, updated)) + + self._upsert_tarifset(self._section(root, 'tarifsets'), + tarif_map, protocol) + tarifset_map = {s.tasy_xml_id: s for s in self.env[ + 'l10n.at.payroll.tasy.tarifset'].search([])} + tarifgruppe_map = {t.tasy_xml_id: t for t in tarifgruppe_obj + .search([])} + + # 3) Verrechnungsbasis-/Positionstypen + Kombinationen + # auflösen, dann tarifgruppeSetZuord importieren. + basis_typ = {b.get('xmlId'): self._tasy_text(b, 'code') + for b in self._section(root, 'verrechBasisTypen')} + pos_typ = {p.get('xmlId'): self._tasy_text(p, 'code') + for p in self._section(root, 'verrechPosTypen')} + kombinationen = {} + for k in self._section(root, 'verrechBasisPositionKombinationen'): + kombinationen[k.get('xmlId')] = ( + basis_typ.get(self._tasy_text(k, 'verrechnungBasisTyp'), + ''), + pos_typ.get(self._tasy_text(k, 'verrechnungPosTyp'), ''), + ) + + zuord_obj = self.env['l10n.at.payroll.tasy.tarifset.zuord'] + created = updated = 0 + for node in self._section(root, 'tarifgruppeSetZuordnungen'): + tarifgruppe = tarifgruppe_map.get( + self._tasy_text(node, 'tarifgruppe')) + tarifset = tarifset_map.get(self._tasy_text(node, 'tarifset')) + von = self._tasy_text(node, 'vonDatum') + if not tarifgruppe or not tarifset or not von: + continue + basis, pos = kombinationen.get( + self._tasy_text(node, 'verrechBasPosKombination'), + ('', '')) + vals = { + 'tarifgruppe_id': tarifgruppe.id, + 'tarifset_id': tarifset.id, + 'basis_typ': basis, + 'pos_typ': pos, + 'traegergruppe': self._tasy_text(node, 'traegergruppe'), + 'von_datum': fields.Date.to_date(von), + 'bis_datum': fields.Date.to_date( + self._tasy_text(node, 'bisDatum') or '2999-12-31'), + 'tasy_xml_id': node.get('xmlId'), + } + rec = zuord_obj.search([ + ('tarifgruppe_id', '=', tarifgruppe.id), + ('tarifset_id', '=', tarifset.id), + ('basis_typ', '=', basis), + ('pos_typ', '=', pos), + ('von_datum', '=', vals['von_datum'])], limit=1) + if rec: + rec.write(vals) + updated += 1 + else: + zuord_obj.create(vals) + created += 1 + protocol.append('Tarifgruppe-Tarifset-Zuordnungen: ' + '%d neu, %d aktualisiert' % (created, updated)) + + # 4) Zu-/Abschlag-Zuordnungen (m2m auf Tarifsets/Tarifgruppen). + verknuepft = 0 + for node in self._section(root, 'zuAbschlagTarifsetZuordnungen'): + zuabschlag = zuabschlag_map.get( + self._tasy_text(node, 'zuAbschlag')) + if not zuabschlag: + continue + tarifset = tarifset_map.get( + self._tasy_text(node, 'tarifsetByTarifsetId')) + tarifgruppen = [ + tarifgruppe_map[r] for r in self._tasy_texts( + node, 'tarifgruppe') if r in tarifgruppe_map] + if tarifset: + zuabschlag.tarifset_ids = [(4, tarifset.id)] + for tarifgruppe in tarifgruppen: + zuabschlag.tarifgruppe_ids = [(4, tarifgruppe.id)] + verknuepft += 1 + protocol.append('Zu-/Abschlag-Zuordnungen: %d verknüpft' + % verknuepft) + + self.protocol = '\n'.join(protocol) + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _('Tarifsystem-Katalog importiert.'), + 'next': {'type': 'ir.actions.act_window_close'}, + }, } \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/tasy_katalog.py b/addons/l10n_at_hr_payroll/models/tasy_katalog.py new file mode 100644 index 0000000..ad40afa --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/tasy_katalog.py @@ -0,0 +1,300 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""SV-Tarifsystem-Katalog (AP0, Variante b): Beschäftigtengruppen +(BSGR), Ergänzungen (ERGB), Dienstgebergruppen, Tarifgruppen, +Tarifsets/Tarife und Zu-/Abschläge — geladen aus dem TASY-LSWH-Export +(sozialversicherung.at/tarifsystem; DM-ORG D.48/D.49: Tarifgruppe = +BSGR + ERGB). + +Der Katalog wird zur Laufzeit über den Import-Wizard +(``l10n.at.payroll.tasy.katalog.wizard``, models/tasy.py) gepflegt — +alle Trägergruppen, versioniert über von/bis-Datum; kein XML-Seed +(keine erfundenen Werte). Die mBGM-Tarifierung des Privatmoduls liest +die Sätze von hier (Katalog vor Hand-Parameter ``at_mbgm_tarif``). +""" +from odoo import api, fields, models + + +class L10nAtPayrollTasyBsgr(models.Model): + _name = 'l10n.at.payroll.tasy.bsgr' + _description = 'SV-Tarifsystem: Beschäftigtengruppe (BSGR)' + _order = 'code, von_datum' + + code = fields.Char(required=True, index=True) + bezeichnung = fields.Char() + kurzbezeichnung = fields.Char() + beschreibung = fields.Char() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + + _unique_bsgr = models.Constraint( + 'unique (traegergruppe, code, von_datum)', + "Je Trägergruppe, Code und Gültig-ab gibt es nur eine " + "Beschäftigtengruppe.") + + +class L10nAtPayrollTasyErgaenzung(models.Model): + _name = 'l10n.at.payroll.tasy.ergaenzung' + _description = 'SV-Tarifsystem: Ergänzung zur BSGR (ERGB)' + _order = 'code, von_datum' + + code = fields.Char(required=True, index=True) + bezeichnung = fields.Char() + kurzbezeichnung = fields.Char() + beschreibung = fields.Char() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + + _unique_ergaenzung = models.Constraint( + 'unique (traegergruppe, code, von_datum)', + "Je Trägergruppe, Code und Gültig-ab gibt es nur eine " + "Ergänzung.") + + +class L10nAtPayrollTasyDienstgebergruppe(models.Model): + _name = 'l10n.at.payroll.tasy.dienstgebergruppe' + _description = 'SV-Tarifsystem: Dienstgebergruppe' + _order = 'code, von_datum' + + code = fields.Char(required=True, index=True) + bezeichnung = fields.Char() + kurzbezeichnung = fields.Char() + beschreibung = fields.Char() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + + _unique_dgg = models.Constraint( + 'unique (traegergruppe, code, von_datum)', + "Je Trägergruppe, Code und Gültig-ab gibt es nur eine " + "Dienstgebergruppe.") + + +class L10nAtPayrollTasyTarif(models.Model): + """Einzelner Tarifsatz (Zweig × DN/DG-Anteil × Gültigkeit). + + Kein natürlicher Code im Export — Identität ist der Inhalt + (Zweig, Sätze, Gültigkeit); der Katalog-Import vereinheitlicht + über diese Inhaltsmerkmale statt über die wechselnden xmlIds. + """ + _name = 'l10n.at.payroll.tasy.tarif' + _description = 'SV-Tarifsystem: Tarif (Zweig-Satz)' + _order = 'zweig, von_datum' + + zweig = fields.Char(required=True, index=True) + dn = fields.Float(string='DN-Anteil (%)', digits=(16, 3)) + dg = fields.Float(string='DG-Anteil (%)', digits=(16, 3)) + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + tarifset_ids = fields.Many2many( + 'l10n.at.payroll.tasy.tarifset', + 'l10n_at_payroll_tasy_tarif_tarifset_rel', + 'tarif_id', 'tarifset_id', string='Tarifsets') + + +class L10nAtPayrollTasyTarifset(models.Model): + _name = 'l10n.at.payroll.tasy.tarifset' + _description = 'SV-Tarifsystem: Tarifset' + _order = 'kurzbezeichnung, von_datum' + + bezeichnung = fields.Char() + kurzbezeichnung = fields.Char() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + tarif_ids = fields.Many2many( + 'l10n.at.payroll.tasy.tarif', + 'l10n_at_payroll_tasy_tarif_tarifset_rel', + 'tarifset_id', 'tarif_id', string='Tarife') + + _unique_tarifset = models.Constraint( + 'unique (traegergruppe, kurzbezeichnung, von_datum)', + "Je Trägergruppe, Kurzbezeichnung und Gültig-ab gibt es nur " + "ein Tarifset.") + + +class L10nAtPayrollTasyTarifgruppe(models.Model): + """Tarifgruppe = BSGR (+ Ergänzungen) × Dienstgebergruppen. + + Auflösung: ``_l10n_at_find`` (BSGR + ERGB-Menge + DGG am Stichtag) + → ``_l10n_at_saetze`` (Sätze je Verrechnungsbasis aus dem + zugeordneten Tarifset). + """ + _name = 'l10n.at.payroll.tasy.tarifgruppe' + _description = 'SV-Tarifsystem: Tarifgruppe' + _order = 'code, von_datum' + + code = fields.Char(required=True, index=True) + bezeichnung = fields.Char() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + bsgr_ids = fields.Many2many( + 'l10n.at.payroll.tasy.bsgr', + 'l10n_at_payroll_tasy_tarifgruppe_bsgr_rel', + 'tarifgruppe_id', 'bsgr_id', string='Beschäftigtengruppen') + ergaenzung_ids = fields.Many2many( + 'l10n.at.payroll.tasy.ergaenzung', + 'l10n_at_payroll_tasy_tarifgruppe_ergaenzung_rel', + 'tarifgruppe_id', 'ergaenzung_id', string='Ergänzungen (ERGB)') + dienstgebergruppe_ids = fields.Many2many( + 'l10n.at.payroll.tasy.dienstgebergruppe', + 'l10n_at_payroll_tasy_tarifgruppe_dgg_rel', + 'tarifgruppe_id', 'dienstgebergruppe_id', + string='Dienstgebergruppen') + fallweise_zulaessig = fields.Boolean(string='Fallweise zulässig') + bv_zulaessig = fields.Boolean( + string='Betriebliche Vorsorge zulässig') + zuord_ids = fields.One2many( + 'l10n.at.payroll.tasy.tarifset.zuord', 'tarifgruppe_id', + string='Tarifset-Zuordnungen') + + _unique_tarifgruppe = models.Constraint( + 'unique (traegergruppe, code, von_datum)', + "Je Trägergruppe, Code und Gültig-ab gibt es nur eine " + "Tarifgruppe.") + + @api.model + def _l10n_at_find(self, bsgr_code, ergaenzung_codes, dgg_code, + stichtag): + """Tarifgruppe zu BSGR + ERGB-Menge + DGG am Stichtag. + + Die ERGB-Menge muss exakt passen (leere Menge = Normalfall + ohne Ergänzung) — DM-ORG D.48/D.49. Liefert leere Recordset, + wenn keine Zeile existiert (Aufrufer entscheidet über + Fallback/Abbruch). + """ + ergaenzung_codes = set(ergaenzung_codes or ()) + kandidaten = self.search([ + ('bsgr_ids.code', '=', bsgr_code), + ('von_datum', '<=', stichtag), + ('bis_datum', '>=', stichtag), + ]) + if dgg_code: + kandidaten = kandidaten.filtered( + lambda t: dgg_code + in t.dienstgebergruppe_ids.mapped('code')) + for tarifgruppe in kandidaten: + if {e.code for e in tarifgruppe.ergaenzung_ids} \ + == ergaenzung_codes: + return tarifgruppe + return self.env['l10n.at.payroll.tasy.tarifgruppe'] + + def _l10n_at_saetze(self, basis_typ, stichtag): + """Sätze je Verrechnungsbasis (AB/SZ/BV/UU …) am Stichtag. + + Rückgabe ``None`` ohne gültige Tarifset-Zuordnung, sonst + ``{'dn': x, 'dg': y, 'gesamt': x + y, 'komponenten': tarif}``. + Je Zweig zählt der späteste gültige Tarif (Schutz gegen + duplette Import-Zeilen). + """ + self.ensure_one() + zuord = self.zuord_ids.filtered( + lambda z: z.basis_typ == basis_typ + and z.von_datum <= stichtag <= z.bis_datum) + if not zuord: + return None + zuord = zuord.sorted(key=lambda z: z.von_datum)[-1] + tarife = zuord.tarifset_id.tarif_ids.filtered( + lambda t: t.von_datum <= stichtag <= t.bis_datum) + komponenten = [] + gesehene_zweige = set() + for tarif in tarife.sorted(key=lambda t: t.von_datum, + reverse=True): + if tarif.zweig in gesehene_zweige: + continue + gesehene_zweige.add(tarif.zweig) + komponenten.append(tarif) + dn = sum(t.dn for t in komponenten) + dg = sum(t.dg for t in komponenten) + return {'dn': dn, 'dg': dg, 'gesamt': dn + dg, + 'komponenten': komponenten} + + +class L10nAtPayrollTasyTarifsetZuord(models.Model): + """tarifgruppeSetZuord: Tarifgruppe × Tarifset × Basis/Position.""" + _name = 'l10n.at.payroll.tasy.tarifset.zuord' + _description = 'SV-Tarifsystem: Tarifgruppe-Tarifset-Zuordnung' + _order = 'tarifgruppe_id, basis_typ, von_datum' + + tarifgruppe_id = fields.Many2one( + 'l10n.at.payroll.tasy.tarifgruppe', required=True, index=True, + ondelete='cascade') + tarifset_id = fields.Many2one( + 'l10n.at.payroll.tasy.tarifset', required=True, index=True, + ondelete='cascade') + basis_typ = fields.Char(string='Verrechnungsbasis', index=True) + pos_typ = fields.Char(string='Verrechnungsposition', index=True) + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + + _unique_zuord = models.Constraint( + 'unique (tarifgruppe_id, tarifset_id, basis_typ, pos_typ, ' + 'von_datum)', + "Diese Tarifgruppe-Tarifset-Zuordnung existiert bereits.") + + +class L10nAtPayrollTasyZuabschlag(models.Model): + _name = 'l10n.at.payroll.tasy.zuabschlag' + _description = 'SV-Tarifsystem: Zu-/Abschlag' + _order = 'code, von_datum' + + code = fields.Char(required=True, index=True) + bezeichnung = fields.Char() + kurzbezeichnung = fields.Char() + beschreibung = fields.Char() + zuabschlagkz = fields.Char(string='Zu-/Abschlag-Kennzeichen') + versicherungsrelevant = fields.Boolean() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) + steuerung_ids = fields.One2many( + 'l10n.at.payroll.tasy.zuabschlag.steuerung', 'zuabschlag_id', + string='Steuerung (Alters-/Einkommensgrenzen)') + tarifset_ids = fields.Many2many( + 'l10n.at.payroll.tasy.tarifset', + 'l10n_at_payroll_tasy_zuabschlag_tarifset_rel', + 'zuabschlag_id', 'tarifset_id', string='Tarifsets') + tarifgruppe_ids = fields.Many2many( + 'l10n.at.payroll.tasy.tarifgruppe', + 'l10n_at_payroll_tasy_zuabschlag_tarifgruppe_rel', + 'zuabschlag_id', 'tarifgruppe_id', string='Tarifgruppen') + + _unique_zuabschlag = models.Constraint( + 'unique (traegergruppe, code, von_datum)', + "Je Trägergruppe, Code und Gültig-ab gibt es nur einen " + "Zu-/Abschlag.") + + +class L10nAtPayrollTasyZuabschlagSteuerung(models.Model): + """Steuerung eines Zu-/Abschlags (Altersgrenzen, Geschlecht, + Geburtsjahrgang) — informativ; die mBGM-Abschlagslogik rechnet + aus den GP4-Stichtagsflags, nicht aus dieser Tabelle.""" + _name = 'l10n.at.payroll.tasy.zuabschlag.steuerung' + _description = 'SV-Tarifsystem: Zu-/Abschlag-Steuerung' + _order = 'zuabschlag_id, von_datum' + + zuabschlag_id = fields.Many2one( + 'l10n.at.payroll.tasy.zuabschlag', required=True, index=True, + ondelete='cascade') + altersgrenze_min_jahr = fields.Float( + string='Altersgrenze min. (Jahre)', digits=(16, 1)) + altersgrenze_soll_jahr = fields.Float( + string='Altersgrenze soll (Jahre)', digits=(16, 1)) + geboren_bis = fields.Date(string='Geboren bis') + geschlecht = fields.Char() + traegergruppe = fields.Char(index=True) + von_datum = fields.Date(string='Gültig ab', required=True) + bis_datum = fields.Date(string='Gültig bis', required=True) + tasy_xml_id = fields.Char(index=True) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/report/belege_reports.xml b/addons/l10n_at_hr_payroll/report/belege_reports.xml index 67bda44..7766fa7 100644 --- a/addons/l10n_at_hr_payroll/report/belege_reports.xml +++ b/addons/l10n_at_hr_payroll/report/belege_reports.xml @@ -38,6 +38,17 @@ report + + Dienstgeber-Gesamtkosten + hr.payslip.run + qweb-pdf + l10n_at_hr_payroll.report_dg_kosten + l10n_at_hr_payroll.report_dg_kosten + + + report + + - + + + + + +