Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/kollektivvertrag.py
T
fegger 9b4221fcef [IMP] l10n_at_hr_payroll_private: Wr. DAG, Buchungsbeleg, VWGJ-Einstufung, EFZG-Teilentgelt mit fiktiver BV (AP2d+AP3+AP9+AP4)
AP3 — Wiener Dienstgeberabgabe (U-Bahn-Steuer, RIS LrW GesNr
20000133 verifiziert): Regel WIEN_DAG (Kopfpauschale 2 Euro je DN
und angefangene Woche, Wochenlogik Montag-im-Monat, Befreiungen
Lehrling/55+/<=10 WStd/Karenz-Woche), Company-Flag-Verwertung,
Parameter at_wien_dag_satz, FI 6663/3560 (Referenz-Rest)

AP2d — Buchungsbeleg-Report am Lohnlauf (Konto/Belegtext/Soll/Haben
der FI-Buchung, balancierte Summen; test_account_private-Erweiterung)

AP9 — Vordienstzeiten -> Einstufung: Stufen-Schwelle vwgj_ab an
l10n.at.payroll.kv.gruppe.stufe (aus Labels 'nach N VWGJ'; -1 =
nicht gepflegt, ORM liest Integer-NULL als 0), CSV-Seed,
_get_stufe_fuer_vwgj/_get_naechste_vwgj, Vertragsfeld
l10n_at_vordienstzeit_monate + VWGJ-Vorschlag + Vorrueckungstermin
(S 17 Abs 5 Rahmen-KV, Erster des Erreichungsmonats) + Uebernahme-
Button (manueller Verwaltungsakt)

AP4 — EFZG/Teilentgelt (AngG S 8/EFZG S 2 verifiziert, Gleichlauf
6/8/10/12+4 Wochen; lb-krs-07/08): EFZ-Phasenmaschine models/efzg.py
(Kalendertage-Kontingente je Arbeitsjahr, Krank-/AU-Topf getrennt,
AU-Urlaubsart ATP_AU), Regeln TEIL_ENTGELT_HALB/-50 % und
KRANKENTGELT_PHASE/-100 % je Arbeitstag (Tagesatz aus unreduzierter
Basis), fiktive BV-Grundlage S 7 Abs 3 BMSVG im _at_bv_basis_laufen-
Hook (50 % Vormonatsentgelt x Bezugs-KT/30), Parameter at_efz_stufen;
Krankengeldumlage S 62 ASVG = Falsch-Praemise (kein Baustein);
Rechenfaelle docs/rechenfaelle/efzg.md
2026-09-12 23:17:57 +02:00

268 lines
11 KiB
Python

# Part of the odoo-at-payroll project. License: LGPL-3.
"""KV-Katalogmodelle (GP1): Kollektivvertrag, versionierte KV-Eckwerte,
Verwendungs-/Lohngruppen mit Erfahrungsstufen.
Datenbasis ist die KV-Library (``personalverrechnung/quellen/kv/``);
die Seed-Kataloge der Referenz-KVs SI-2203/SI-2748 (Plan-Entscheidung
D2) sind aus den Volltexten kuratiert. Eckwerte folgen dem Muster
``hr.rule.parameter.value`` (Python-Literal, Versionierung über den
Stichtag), sind aber KV-scoped und werden daher bewusst nicht über
``payslip._rule_parameter`` konsumiert, sondern über
``employee.kv_id._wert('code', datum)``.
"""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
class L10nAtPayrollKv(models.Model):
"""Kollektivvertrag als Entgelt-Rechtsquelle der Privatwirtschaft.
Normative Wirkung über ArbVG § 12; Quellenpolitik der KV-Library:
Main Source = Arbeitgeberkammer, kollektivvertrag.at als
Crosscheck-Baseline.
"""
_name = 'l10n.at.payroll.kv'
_description = 'Kollektivvertrag (AT Privatwirtschaft)'
_order = 'name'
name = fields.Char(required=True)
code = fields.Char(
required=True, index=True,
help="Kurzbezeichnung, üblicherweise die KV-Library-Variante "
"(z. B. SI-2203).")
arbeitgeberkammer = fields.Selection([
('wko', 'Wirtschaftskammer Österreich (WKO)'),
('lwk', 'Landwirtschaftskammer'),
('aerztekammer', 'Ärztekammer'),
('apotheke', 'Apothekerkammer'),
('rechtsanwaltskammer', 'Rechtsanwaltskammer'),
('notariatskammer', 'Notariatskammer'),
])
gewerkschaft = fields.Char(
help="Verantwortliche Gewerkschaft(en), z. B. GPA; mehrere "
"kommagetrennt.")
gueltig_ab = fields.Date(
string='Fassung gültig ab',
help="Gültigkeit der hinterlegten KV-Fassung (Lohnrunde), z. B. "
"2026-01-01 für die Gehaltstabellen ab 1. 1. 2026.")
library_variant_id = fields.Char(
string='KV-Library-Variante',
help="Trace-Link: variant_id im Katalog "
"personalverrechnung/quellen/kv/kv-catalog.json.")
quelle_url = fields.Char(
string='Quelle (Arbeitgeberkammer)',
help="Veröffentlichung der zuständigen Arbeitgeberkammer "
"(Main Source gemäß KV-Library-Quellenarchitektur).")
active = fields.Boolean(default=True)
gruppe_ids = fields.One2many(
'l10n.at.payroll.kv.gruppe', 'kv_id', string='Verwendungsgruppen')
wert_ids = fields.One2many(
'l10n.at.payroll.kv.wert', 'kv_id', string='KV-Eckwerte')
_unique_kv = models.Constraint(
'unique (code)',
"Ein Kollektivvertrag existiert je Code nur einmal.")
def _wert(self, code, datum=None, raise_if_not_found=True):
"""Eckwert zum Stichtag (z. B. 'naz', 'sz13_monat').
Rückgabe wie hr.rule.parameter: Python-Literal aus
parameter_value (Zahl, String oder Boolean).
"""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
wert = self.env['l10n.at.payroll.kv.wert'].search([
('kv_id', '=', self.id),
('code', '=', code),
('date_from', '<=', datum)], order='date_from desc', limit=1)
if wert:
return safe_eval(wert.parameter_value)
if raise_if_not_found:
raise UserError(_(
"Kein KV-Eckwert %(code)s für %(kv)s zum %(datum)s "
"gefunden.", code=code, kv=self.display_name,
datum=datum))
return None
@api.depends('name', 'code')
def _compute_display_name(self):
for kv in self:
kv.display_name = f"{kv.name} ({kv.code})" if kv.code else kv.name
class L10nAtPayrollKvWert(models.Model):
"""Versionierter KV-Eckwert (Muster: hr.rule.parameter.value)."""
_name = 'l10n.at.payroll.kv.wert'
_description = 'KV-Eckwert (versioniert)'
_order = 'kv_id, code, date_from desc'
kv_id = fields.Many2one(
'l10n.at.payroll.kv', required=True, index=True, ondelete='cascade')
code = fields.Char(
required=True, index=True,
help="Code für den Regelkonsum, z. B. 'naz' "
"(Normalarbeitszeit), 'teilungsfaktor_ue', "
"'sz13_monat', 'sz14_faelligkeit'.")
name = fields.Char(string='Bezeichnung')
parameter_value = fields.Text(
required=True,
help="Python-Literal (Zahl, String oder Boolean) — Muster "
"hr.rule.parameter.value.")
date_from = fields.Date(
string='Gültig ab', required=True, index=True,
default=lambda self: fields.Date.today().replace(month=1, day=1))
_unique_kv_wert = models.Constraint(
'unique (kv_id, code, date_from)',
"Je KV, Code und Stichtag gibt es nur einen Wert.")
@api.constrains('parameter_value')
def _check_parameter_value(self):
for wert in self:
try:
safe_eval(wert.parameter_value)
except Exception:
raise UserError(_(
"Ungültiger KV-Eckwert für %(code)s: "
"Python-Literal (Zahl, 'String' oder Boolean) erwartet.",
code=wert.code))
class L10nAtPayrollKvGruppe(models.Model):
"""Verwendungs-/Lohngruppe eines Kollektivvertrages (z. B. VG III).
Die Monatsgehälter je Erfahrungsstufe stehen auf
``l10n.at.payroll.kv.gruppe.stufe`` (Jahreswerte, ein Wert je
Stichtag). Angestellte/Arbeiter-Unterschiede laufen über die
``lohn_basis`` und die KV-Eckwerte, nicht über eigene Strukturen
(Plan 4.2).
"""
_name = 'l10n.at.payroll.kv.gruppe'
_description = 'KV-Verwendungs-/Lohngruppe'
_order = 'kv_id, code'
kv_id = fields.Many2one(
'l10n.at.payroll.kv', required=True, index=True, ondelete='cascade')
code = fields.Char(required=True, index=True)
name = fields.Char(string='Bezeichnung')
lohn_basis = fields.Selection([
('monatlich', 'Monatsgehalt (vollzeit)'),
('wochenstunden', 'Wochenstunden-Basis (Arbeiter)'),
], required=True, default='monatlich')
active = fields.Boolean(default=True)
stufe_ids = fields.One2many(
'l10n.at.payroll.kv.gruppe.stufe', 'gruppe_id',
string='Erfahrungsstufen')
_unique_kv_gruppe = models.Constraint(
'unique (kv_id, code)',
"Je Kollektivvertrag existiert eine Gruppe nur einmal.")
@api.depends('kv_id', 'code', 'name')
def _compute_display_name(self):
for gruppe in self:
gruppe.display_name = (
f"{gruppe.name or gruppe.code} "
f"[{gruppe.kv_id.display_name}]")
def _get_gehalt(self, stufe, datum=None):
"""Stufen-Position (Monatsgehalt) zum Stichtag (oder leer)."""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
return self.env['l10n.at.payroll.kv.gruppe.stufe'].search(
[('gruppe_id', '=', self.id),
('stufe', '=', stufe),
('gueltig_ab', '<=', datum)],
order='gueltig_ab desc', limit=1)
def _get_stufe_fuer_vwgj(self, vwgj_jahre, datum=None):
"""Erfahrungsstufe für vollendete Verwendungsgruppenjahre
(VWGJ) zum Stichtag — AP9. Basis: vwgj_ab der Stufenzeilen
(„nach N VWGJ"; 0 = ab Beginn). Gibt die höchste Stufe mit
vwgj_ab <= vwgj_jahre zurück; 0, wenn die Gruppe keine
gepflegten Schwellen hat (manuelle Einstufung maßgeblich).
Rahmen-KV § 17 Abs 3-5 (Biennal-/Triennalsprünge, Vorrückung
am Ersten des Erreichungsmonats)."""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
# -1 = Schwelle nicht gepflegt (ORM liest Integer-NULL als 0 —
# deshalb Konvention -1 statt NULL; Domain > -1 filtert beides).
zeilen = self.env['l10n.at.payroll.kv.gruppe.stufe'].search(
[('gruppe_id', '=', self.id),
('gueltig_ab', '<=', datum),
('vwgj_ab', '>', -1)])
beste = zeilen.filtered(lambda z: z.vwgj_ab <= vwgj_jahre)
if not beste:
return 0
return max(beste.mapped('stufe'))
def _get_naechste_vwgj(self, vwgj_jahre, datum=None):
"""Nächste VWGJ-Schwelle oberhalb der erreichten Jahre (für
den Vorrückungstermin, § 17 Abs 5) — None an der Höchststufe."""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
zeilen = self.env['l10n.at.payroll.kv.gruppe.stufe'].search(
[('gruppe_id', '=', self.id),
('gueltig_ab', '<=', datum),
('vwgj_ab', '>', -1)])
schwellen = sorted(set(zeilen.mapped('vwgj_ab')))
for schwelle in schwellen:
if schwelle > vwgj_jahre:
return schwelle
return None
def _max_stufe(self, datum=None):
"""Höchste Stufe mit gültigem Monatsgehalt zum Stichtag."""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
stufe = self.env['l10n.at.payroll.kv.gruppe.stufe'].search(
[('gruppe_id', '=', self.id),
('gueltig_ab', '<=', datum)],
order='stufe desc', limit=1)
return stufe.stufe or 0
class L10nAtPayrollKvGruppeStufe(models.Model):
"""Monatsgehalt einer KV-Gruppe in einer Erfahrungsstufe ab einem
Stichtag (Jahrestabellen aus der KV-Lohnrunde)."""
_name = 'l10n.at.payroll.kv.gruppe.stufe'
_description = 'KV-Monatsgehalt je Erfahrungsstufe'
_order = 'gruppe_id, stufe, gueltig_ab desc'
_rec_names_search = ['gruppe_id']
gruppe_id = fields.Many2one(
'l10n.at.payroll.kv.gruppe', required=True, index=True,
ondelete='cascade')
stufe = fields.Integer(required=True, index=True)
bezeichnung = fields.Char(
help="Stufen-Label der KV-Tabelle, z. B. 'im 1. u 2. VWGJ' "
"oder 'nach 15 VWGJ'.")
vwgj_ab = fields.Integer(
string='Ab (vollendete) VWGJ', index=True, default=-1,
help="Vollendete Verwendungsgruppenjahre, ab denen die Stufe "
"gebührt — aus dem Stufen-Label (§ 17 Abs 3-5 "
"Rahmen-KV: Biennal-/Triennalsprünge). 0 = ab Beginn; "
"-1 = Schwelle nicht gepflegt → kein Einstufungs-"
"Vorschlag (manuelle Einstufung maßgeblich). AP9.")
gehalt = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id)
gueltig_ab = fields.Date(
required=True, index=True,
default=lambda self: fields.Date.today().replace(month=1, day=1))
_unique_kv_stufe = models.Constraint(
'unique (gruppe_id, stufe, gueltig_ab)',
"Je Gruppe und Stufe gibt es nur einen Wert je Stichtag.")
@api.depends('gruppe_id', 'stufe', 'gehalt', 'gueltig_ab')
def _compute_display_name(self):
for stufe in self:
stufe.display_name = _(
"%(gruppe)s, Stufe %(stufe)s (%(betrag)s ab %(ab)s)",
gruppe=stufe.gruppe_id.code,
stufe=stufe.stufe,
betrag=stufe.gehalt,
ab=stufe.gueltig_ab,
)