mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
67374611c6
GP1 of the approved General-AT implementation plan: the KV framework for the Austrian private economy on the Odoo 19 hr_payroll engine. - l10n.at.payroll.kv: collective agreement master data with chamber, union, validity date and a trace link to the KV library (personalverrechnung/quellen/kv/) - l10n.at.payroll.kv.wert: versioned KV parameters following the hr.rule.parameter.value pattern (Python literal, unique per KV/code/date); consumed via employee.kv_id._wert(code, date) - l10n.at.payroll.kv.gruppe / .stufe: Verwendungsgruppen with Erfahrungsstufen year tables, unique(gruppe, stufe, gueltig_ab), Stichtag lookup - hr.version / hr.employee contract fields: kv_id, kv_gruppe_id, erfahrungsstufe, ueberzahlung - import wizard for KV intake and yearly updates: table paste (Gruppe;Stufe;Gehalt[;Bezeichnung]), preview, >10% jump warning (warn lines never applied automatically), new gueltig_ab versions, historical values untouched - seed D2 (SI-2203 Handwerk und Gewerbe, SI-2748 Metallgewerbe): wage tables from 1. 1. 2026 curated from the KV library texts - 18 groups, 131 stage values, 27 parameters, including the special progression groups IV-M / V-OM (SI-2748) and the discontinued VG I (SI-2203) - tests: test_kv_katalog (seed lookups, Stichtag behaviour, versioning, parameters, wizard, contract fields) Statically validated (py_compile, XML well-formedness, CSV consistency, wizard logic simulation); runtime acceptance pending PostgreSQL on the dev host.
225 lines
8.9 KiB
Python
225 lines
8.9 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 _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'.")
|
|
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,
|
|
) |