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
+
+
@@ -170,13 +181,15 @@
-
+
+
Monatsjournal —
+
Bezüge & Abzüge
@@ -193,6 +206,92 @@
|
|
+
+ | Summe Bezüge & Abzüge |
+ |
+
+
+
+
Dienstgeberanteile (Lohnnebenkosten)
+
+
+
+ | Code |
+ Position |
+ Bemessung |
+ Summe |
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+ | Summe Dienstgeberanteile |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dienstgeber-Gesamtkosten —
+
+
+
+ | Mitarbeiter:in |
+ Bruttolohn |
+ SV-DG (laufend+SZ) |
+ FLAF-DB |
+ KommSt |
+ DZ (WKO) |
+ BV |
+ Wr. DAG |
+ Weitere DG |
+ Σ Lohnnebenkosten |
+ Gesamtkosten |
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+ | Summe Lauf |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
diff --git a/addons/l10n_at_hr_payroll/report/jahresreports.xml b/addons/l10n_at_hr_payroll/report/jahresreports.xml
new file mode 100644
index 0000000..2b48b2c
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/report/jahresreports.xml
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+ Jahreslohnkonto (Dienstnehmer:in)
+ hr.employee
+ qweb-pdf
+ l10n_at_hr_payroll.report_jahreslohnkonto
+ l10n_at_hr_payroll.report_jahreslohnkonto
+
+
+
+
+ Betriebsjahreslohnkonto
+ res.company
+ qweb-pdf
+ l10n_at_hr_payroll.report_betriebslohnkonto
+ l10n_at_hr_payroll.report_betriebslohnkonto
+
+
+
+
+
+
+
+
+
Jahreslohnkonto —
+
+
+
+ | Code |
+ Lohnart |
+ Kategorie |
+
+
+ |
+ Jahr |
+
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Betriebsjahreslohnkonto —
+
+
+
+ | Code |
+ Lohnart |
+ Kategorie |
+
+
+ |
+ Jahr |
+
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+ l10n.at.payroll.jahresbericht.wizard.form
+ l10n.at.payroll.jahresbericht.wizard
+
+
+
+
+
+
+ Jahresberichte (Lohnkonten)
+ l10n.at.payroll.jahresbericht.wizard
+ form
+ new
+
+
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/security/ir.model.access.csv b/addons/l10n_at_hr_payroll/security/ir.model.access.csv
index 8650627..1c2fd20 100644
--- a/addons/l10n_at_hr_payroll/security/ir.model.access.csv
+++ b/addons/l10n_at_hr_payroll/security/ir.model.access.csv
@@ -12,4 +12,24 @@ access_l10n_at_payroll_frist_user,L10n AT Payroll Frist User,model_l10n_at_payro
access_l10n_at_payroll_frist_manager,L10n AT Payroll Frist Manager,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_jahresretro_wizard,L10n AT Payroll Jahresretro Wizard,model_l10n_at_payroll_jahresretro_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_jahresretro_ergebnis,L10n AT Payroll Jahresretro Ergebnis,model_l10n_at_payroll_jahresretro_ergebnis,hr_payroll.group_hr_payroll_user,1,1,1,1
-access_l10n_at_payroll_mitteilungsfile_wizard,L10n AT Payroll Mitteilungsfile Wizard,model_l10n_at_payroll_mitteilungsfile_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
\ No newline at end of file
+access_l10n_at_payroll_mitteilungsfile_wizard,L10n AT Payroll Mitteilungsfile Wizard,model_l10n_at_payroll_mitteilungsfile_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
+access_l10n_at_payroll_jahresbericht_wizard,L10n AT Payroll Jahresbericht Wizard,model_l10n_at_payroll_jahresbericht_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
+access_l10n_at_payroll_tasy_katalog_wizard,L10n AT Payroll TASY-Katalog Wizard,model_l10n_at_payroll_tasy_katalog_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
+access_l10n_at_payroll_tasy_bsgr_user,L10n AT Payroll TASY-BSGR User,model_l10n_at_payroll_tasy_bsgr,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_bsgr_manager,L10n AT Payroll TASY-BSGR Manager,model_l10n_at_payroll_tasy_bsgr,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_ergaenzung_user,L10n AT Payroll TASY-Ergänzung User,model_l10n_at_payroll_tasy_ergaenzung,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_ergaenzung_manager,L10n AT Payroll TASY-Ergänzung Manager,model_l10n_at_payroll_tasy_ergaenzung,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_dgg_user,L10n AT Payroll TASY-Dienstgebergruppe User,model_l10n_at_payroll_tasy_dienstgebergruppe,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_dgg_manager,L10n AT Payroll TASY-Dienstgebergruppe Manager,model_l10n_at_payroll_tasy_dienstgebergruppe,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_tarifgruppe_user,L10n AT Payroll TASY-Tarifgruppe User,model_l10n_at_payroll_tasy_tarifgruppe,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_tarifgruppe_manager,L10n AT Payroll TASY-Tarifgruppe Manager,model_l10n_at_payroll_tasy_tarifgruppe,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_tarifset_user,L10n AT Payroll TASY-Tarifset User,model_l10n_at_payroll_tasy_tarifset,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_tarifset_manager,L10n AT Payroll TASY-Tarifset Manager,model_l10n_at_payroll_tasy_tarifset,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_tarif_user,L10n AT Payroll TASY-Tarif User,model_l10n_at_payroll_tasy_tarif,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_tarif_manager,L10n AT Payroll TASY-Tarif Manager,model_l10n_at_payroll_tasy_tarif,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_zuord_user,L10n AT Payroll TASY-Zuordnung User,model_l10n_at_payroll_tasy_tarifset_zuord,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_zuord_manager,L10n AT Payroll TASY-Zuordnung Manager,model_l10n_at_payroll_tasy_tarifset_zuord,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_zuabschlag_user,L10n AT Payroll TASY-Zuabschlag User,model_l10n_at_payroll_tasy_zuabschlag,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_zuabschlag_manager,L10n AT Payroll TASY-Zuabschlag Manager,model_l10n_at_payroll_tasy_zuabschlag,hr_payroll.group_hr_payroll_manager,1,1,1,1
+access_l10n_at_payroll_tasy_zuabschlag_steuerung_user,L10n AT Payroll TASY-Zuabschlag-Steuerung User,model_l10n_at_payroll_tasy_zuabschlag_steuerung,hr_payroll.group_hr_payroll_user,1,0,0,0
+access_l10n_at_payroll_tasy_zuabschlag_steuerung_manager,L10n AT Payroll TASY-Zuabschlag-Steuerung Manager,model_l10n_at_payroll_tasy_zuabschlag_steuerung,hr_payroll.group_hr_payroll_manager,1,1,1,1
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/tests/__init__.py b/addons/l10n_at_hr_payroll/tests/__init__.py
index a06b028..88b46e0 100644
--- a/addons/l10n_at_hr_payroll/tests/__init__.py
+++ b/addons/l10n_at_hr_payroll/tests/__init__.py
@@ -1,5 +1,6 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import test_tasy_import
+from . import test_tasy_katalog
from . import test_lohnsteuer
from . import test_sozialversicherung
from . import test_elda_l16
diff --git a/addons/l10n_at_hr_payroll/tests/test_tasy_katalog.py b/addons/l10n_at_hr_payroll/tests/test_tasy_katalog.py
new file mode 100644
index 0000000..990fc6a
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/tests/test_tasy_katalog.py
@@ -0,0 +1,422 @@
+# Part of the odoo-at-payroll project. License: LGPL-3.
+"""AP0 (Variante b): TASY-Katalog-Import und Tarifgruppen-Auflösung.
+
+Fixture-Slice mit den normativen Normalbetriebe-Werten 2026 (AB 39,05 %,
+SZ 37,55 % — verifiziert gegen den echten TASY-LSWH-Export 2026-07-01)
+plus E01-Ergänzung (NB-Zweig 3,80 % DG, Dienstgebergruppe NBNSA).
+"""
+import base64
+from datetime import date
+
+from odoo.tests import TransactionCase, tagged
+
+TASY_KATALOG_FIXTURE = """
+
+ 2026-07-01
+
+
+ B001
+ Arbeiter
+ Arb.
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ B002
+ Angestellte
+ Ang.
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ B044
+ Angestelltenlehrlinge
+ Ang. Lg.
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ B045
+ Arbeiterlehrlinge
+ Arb. Lg.
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ B010
+ Geringfügig beschäftigte Arbeiter
+ Gfg. Arb.
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+
+ E01
+ Nachtschwerarbeitsbeitrag
+ NSchAB
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+
+ NBALG
+ Normalbetriebe - Allgemeiner Teil
+ NBALG
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ NBNSA
+ Normalbetriebe mit Nachtschwerarbeit
+ NBNSA
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+
+ G001
+ NBAL
+ B001
+ Arbeiter
+ OEGK
+ 2019-01-01
+ 2999-12-31
+ true
+ true
+
+
+ G002
+ NBAL
+ B002
+ Angestellte
+ OEGK
+ 2019-01-01
+ 2999-12-31
+ false
+ true
+
+
+ G002
+ E01X
+ NBNS
+ B002E01
+ Angestellte/Nachtschwerarbeitsbeitrag
+ OEGK
+ 2019-01-01
+ 2999-12-31
+ true
+
+
+ G045
+ NBAL
+ B045
+ Arbeiterlehrlinge
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+
+ KV
+ 3.87
+ 3.78
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ PV
+ 10.25
+ 12.55
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ AV
+ 2.95
+ 2.95
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ UV
+ 0
+ 1.1
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ WF
+ 0.5
+ 0.5
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ AK
+ 0.5
+ 0
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ IE
+ 0
+ 0.1
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+ NB
+ 0
+ 3.8
+ OEGK
+ 2020-01-01
+ 2999-12-31
+
+
+
+
+ 40/AK/AV/IE/KV/PV/UV/WF
+ 40/AK/AV/IE/KV/PV/UV/WF
+ OEGK
+ 2019-01-01
+ 2999-12-31
+ R001
+ R002
+ R003
+ R004
+ R005
+ R006
+ R007
+
+
+ 47/AV/IE/KV/PV/UV
+ 47/AV/IE/KV/PV/UV
+ OEGK
+ 2019-01-01
+ 2999-12-31
+ R001
+ R002
+ R003
+ R004
+ R007
+
+
+ 40/AK/AV/IE/KV/PV/UV/WF/NB
+ 40/AK/AV/IE/KV/PV/UV/WF/NB
+ OEGK
+ 2019-01-01
+ 2999-12-31
+ R001
+ R002
+ R003
+ R004
+ R005
+ R006
+ R007
+ R008
+
+
+
+ ABArbeitsbezugOEGK2019-01-012999-12-31
+ SZSonderzahlungOEGK2019-01-012999-12-31
+
+
+ T01Gesamtsatz laufendOEGK2019-01-012999-12-31
+ T02Gesamtsatz SonderzahlungOEGK2019-01-012999-12-31
+
+
+ VBTABVPT01OEGK2019-01-012999-12-31
+ VBTSZVPT02OEGK2019-01-012999-12-31
+
+
+ T001S001K01OEGK2019-01-012999-12-31
+ T001S002K02OEGK2019-01-012999-12-31
+ T002S001K01OEGK2019-01-012999-12-31
+ T002S002K02OEGK2019-01-012999-12-31
+ T002E01S003K01OEGK2019-01-012999-12-31
+ T002E01S002K02OEGK2019-01-012999-12-31
+ T045S001K01OEGK2019-01-012999-12-31
+ T045S002K02OEGK2019-01-012999-12-31
+
+
+
+ A09
+ Entfall UV 60. Lj
+ UV 60
+ A
+ false
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+
+ A09X
+ 60
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+
+ A09X
+ S001
+ T001
+ T002
+ OEGK
+ 2019-01-01
+ 2999-12-31
+
+
+
+"""
+
+
+@tagged('post_install', '-at_install')
+class TasyKatalogTest(TransactionCase):
+ """Katalog-Import (Fixture + Realexport) und Tarifgruppen-Auflösung."""
+
+ def _import(self, raw=None):
+ import os
+ if raw is None:
+ path = os.path.join(os.path.dirname(__file__), *[
+ '..', '..', '..', 'personalverrechnung', 'quellen',
+ 'TASY-LSWH-Export-20260324.xml'])
+ if not os.path.exists(path):
+ self.skipTest('TASY-Quelldatei nicht im Workingtree')
+ with open(path, 'rb') as fh:
+ raw = fh.read()
+ wizard = self.env['l10n.at.payroll.tasy.katalog.wizard'].create({
+ 'file': base64.b64encode(raw).decode(),
+ 'filename': 'fixture.xml',
+ })
+ wizard.action_import()
+ return wizard
+
+ def test_01_import_fixture_und_aufloesung(self):
+ """Import erzeugt den Katalog; B001/B002 lösen zu den
+ normativen Normalbetriebe-Sätzen auf (AB 39,05, SZ 37,55)."""
+ self._import(TASY_KATALOG_FIXTURE.encode())
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.bsgr'].search_count([]), 5)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.ergaenzung'].search_count([]),
+ 1)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.dienstgebergruppe']
+ .search_count([]), 2)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.tarifgruppe'].search_count([]),
+ 4)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.tarifset'].search_count([]), 3)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.tarifset.zuord']
+ .search_count([]), 8)
+ stichtag = date(2026, 7, 1)
+ for code in ('B001', 'B002'):
+ tarifgruppe = self.env[
+ 'l10n.at.payroll.tasy.tarifgruppe']._l10n_at_find(
+ code, [], 'NBALG', stichtag)
+ self.assertTrue(tarifgruppe, code)
+ ab = tarifgruppe._l10n_at_saetze('AB', stichtag)
+ sz = tarifgruppe._l10n_at_saetze('SZ', stichtag)
+ self.assertAlmostEqual(ab['gesamt'], 39.05, places=2)
+ self.assertAlmostEqual(ab['dn'], 18.07, places=2)
+ self.assertAlmostEqual(ab['dg'], 20.98, places=2)
+ self.assertAlmostEqual(sz['gesamt'], 37.55, places=2)
+
+ def test_02_import_idempotent(self):
+ """Erneuter Import derselben Datei dupliziert nichts."""
+ self._import(TASY_KATALOG_FIXTURE.encode())
+ self._import(TASY_KATALOG_FIXTURE.encode())
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.tarifgruppe'].search_count([]),
+ 4)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.tarif'].search_count([]), 8)
+ self.assertEqual(
+ self.env['l10n.at.payroll.tasy.tarifset.zuord']
+ .search_count([]), 8)
+
+ def test_03_ergaenzung_e01(self):
+ """B002 + E01 + NBNSA → T01 inkl. NB-Zweig 3,80 % DG
+ (42,85 %); ohne Ergänzung unter NBNSA keine Tarifgruppe."""
+ self._import(TASY_KATALOG_FIXTURE.encode())
+ stichtag = date(2026, 7, 1)
+ find = self.env['l10n.at.payroll.tasy.tarifgruppe']._l10n_at_find
+ # B002 + E01 unter NBALG existiert nicht (E01 gehört zu NBNSA)
+ self.assertFalse(find('B002', ['E01'], 'NBALG', stichtag))
+ tarifgruppe = find('B002', ['E01'], 'NBNSA', stichtag)
+ self.assertTrue(tarifgruppe)
+ ab = tarifgruppe._l10n_at_saetze('AB', stichtag)
+ self.assertAlmostEqual(ab['gesamt'], 42.85, places=2)
+ zweige = {t.zweig for t in ab['komponenten']}
+ self.assertIn('NB', zweige)
+ # SZ ohne NB (Sonderzahlungen tragen keinen NSchAB)
+ sz = tarifgruppe._l10n_at_saetze('SZ', stichtag)
+ self.assertAlmostEqual(sz['gesamt'], 37.55, places=2)
+
+ def test_04_lehrlings_bsg(self):
+ """B045 (Arbeiterlehrlinge) ist auflösbar — die
+ Lehrlings-BSGR-Korrektur beruht auf diesen Bezeichnern."""
+ self._import(TASY_KATALOG_FIXTURE.encode())
+ stichtag = date(2026, 7, 1)
+ b044 = self.env['l10n.at.payroll.tasy.bsgr'].search([
+ ('code', '=', 'B044')])
+ b045 = self.env['l10n.at.payroll.tasy.bsgr'].search([
+ ('code', '=', 'B045')])
+ self.assertEqual(b044.bezeichnung, 'Angestelltenlehrlinge')
+ self.assertEqual(b045.bezeichnung, 'Arbeiterlehrlinge')
+ tarifgruppe = self.env[
+ 'l10n.at.payroll.tasy.tarifgruppe']._l10n_at_find(
+ 'B045', [], 'NBALG', stichtag)
+ self.assertTrue(tarifgruppe)
+
+ def test_05_real_export(self):
+ """Echter TASY-LSWH-Export: Katalog-Volumen und die
+ normativen 2026er Sätze aus der Katalogauflösung."""
+ self._import()
+ stichtag = date(2026, 7, 1)
+ find = self.env['l10n.at.payroll.tasy.tarifgruppe']._l10n_at_find
+ for code in ('B001', 'B002'):
+ tarifgruppe = find(code, [], 'NBALG', stichtag)
+ self.assertTrue(tarifgruppe, code)
+ ab = tarifgruppe._l10n_at_saetze('AB', stichtag)
+ sz = tarifgruppe._l10n_at_saetze('SZ', stichtag)
+ self.assertAlmostEqual(ab['gesamt'], 39.05, places=2)
+ self.assertAlmostEqual(sz['gesamt'], 37.55, places=2)
+ # Lehrlings-BSGR-Bezeichner (Korrektur-Grundlage)
+ self.assertEqual(self.env['l10n.at.payroll.tasy.bsgr'].search([
+ ('code', '=', 'B044')]).bezeichnung, 'Angestelltenlehrlinge')
+ self.assertEqual(self.env['l10n.at.payroll.tasy.bsgr'].search([
+ ('code', '=', 'B045')]).bezeichnung, 'Arbeiterlehrlinge')
+ # Geringfügige je Personalgruppe
+ self.assertEqual(self.env['l10n.at.payroll.tasy.bsgr'].search([
+ ('code', '=', 'B010')]).bezeichnung,
+ 'Geringfügig beschäftigte Arbeiter')
+ # Ergänzungen sind im Katalog
+ self.assertTrue(self.env['l10n.at.payroll.tasy.ergaenzung'].search([
+ ('code', '=', 'E01')]))
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/views/company_views.xml b/addons/l10n_at_hr_payroll/views/company_views.xml
index fa8a433..4c28f5a 100644
--- a/addons/l10n_at_hr_payroll/views/company_views.xml
+++ b/addons/l10n_at_hr_payroll/views/company_views.xml
@@ -28,6 +28,7 @@
+
diff --git a/addons/l10n_at_hr_payroll/views/meldewesen_views.xml b/addons/l10n_at_hr_payroll/views/meldewesen_views.xml
index 9ef7be2..6486cfd 100644
--- a/addons/l10n_at_hr_payroll/views/meldewesen_views.xml
+++ b/addons/l10n_at_hr_payroll/views/meldewesen_views.xml
@@ -124,4 +124,26 @@
+
+
+ hr.departure.reason.form.l10n_at_agrd
+ hr.departure.reason
+
+
+
+
+
+
+
+
+
+ hr.departure.reason.list.l10n_at_agrd
+ hr.departure.reason
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/views/tasy_katalog_views.xml b/addons/l10n_at_hr_payroll/views/tasy_katalog_views.xml
new file mode 100644
index 0000000..ac9adbc
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/views/tasy_katalog_views.xml
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+ l10n.at.payroll.tasy.tarifgruppe.list
+ l10n.at.payroll.tasy.tarifgruppe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ l10n.at.payroll.tasy.tarifgruppe.form
+ l10n.at.payroll.tasy.tarifgruppe
+
+
+
+
+
+
+ Tarifgruppen (SV-Tarifsystem)
+ l10n.at.payroll.tasy.tarifgruppe
+ list,form
+
+
+
+ l10n.at.payroll.tasy.bsgr.list
+ l10n.at.payroll.tasy.bsgr
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Beschäftigtengruppen (BSGR)
+ l10n.at.payroll.tasy.bsgr
+ list
+
+
+
+ l10n.at.payroll.tasy.ergaenzung.list
+ l10n.at.payroll.tasy.ergaenzung
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ergänzungen (ERGB)
+ l10n.at.payroll.tasy.ergaenzung
+ list
+
+
+
+ l10n.at.payroll.tasy.zuabschlag.list
+ l10n.at.payroll.tasy.zuabschlag
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Zu-/Abschläge
+ l10n.at.payroll.tasy.zuabschlag
+ list
+
+
+
+
+ l10n.at.payroll.tasy.katalog.wizard.form
+ l10n.at.payroll.tasy.katalog.wizard
+
+
+
+
+
+
+ Tarifsystem-Katalog importieren (TASY)
+ l10n.at.payroll.tasy.katalog.wizard
+ form
+ new
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file