[IMP] l10n_at_hr_payroll: TASY-Tarifsystem-Katalog, AGRD-Auswahl, DG-Kosten-/Lohnkonten-Reports, Wr.-DAG-Grundlagen (AP0-AP3)

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
This commit is contained in:
2026-09-12 23:17:00 +02:00
parent 3c5cc3aded
commit 3c85cc6790
16 changed files with 1999 additions and 35 deletions
+415 -9
View File
@@ -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. <tarife>xmlId</tarife>)."""
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. <tarifgruppe>xmlId</tarifgruppe> 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'},
},
}