mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
9b4221fcef
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
268 lines
11 KiB
Python
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,
|
|
) |