mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[ADD] l10n_at_hr_payroll_private: KV framework (GP1)
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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
from . import kollektivvertrag
|
||||
from . import hr_version_private
|
||||
@@ -0,0 +1,58 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""KV-Einstufung am Arbeitsvertrag (hr.version) und Durchreichung auf
|
||||
hr.employee — Muster ``hr_version_bgld.py`` des GemBG-Moduls.
|
||||
|
||||
Die Stufenlogik der Referenz-KVs läuft über Verwendungsgruppenjahre
|
||||
(VWGJ, Biennal-/Triennalsprünge, § 17 der Rahmen-KVs); die Vorrückung
|
||||
selbst ist GP2-Thema (Wizard analog GemBG), hier werden die
|
||||
Vertragsfelder und der Katalog-Lookup bereitgestellt.
|
||||
"""
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrVersion(models.Model):
|
||||
_inherit = 'hr.version'
|
||||
|
||||
kv_id = fields.Many2one(
|
||||
'l10n.at.payroll.kv', string='Kollektivvertrag', index=True,
|
||||
groups="hr_payroll.group_hr_payroll_user", tracking=True)
|
||||
kv_gruppe_id = fields.Many2one(
|
||||
'l10n.at.payroll.kv.gruppe', string='Verwendungsgruppe', index=True,
|
||||
groups="hr_payroll.group_hr_payroll_user", tracking=True)
|
||||
erfahrungsstufe = fields.Integer(
|
||||
string='Erfahrungsstufe',
|
||||
groups="hr_payroll.group_hr_payroll_user", tracking=True,
|
||||
help="Stufenindex der KV-Gehaltstabelle (1 = unterste Stufe, z. B. "
|
||||
"'im 1. u 2. Verwendungsgruppenjahr').")
|
||||
ueberzahlung = fields.Monetary(
|
||||
string='Überzahlung (individuelles Gehalt)',
|
||||
currency_field='currency_id',
|
||||
groups="hr_payroll.group_hr_payroll_user", tracking=True,
|
||||
help="Individuell vereinbartes Monatsgehalt; überschreibt das "
|
||||
"Kataloggehalt der Gruppe/Stufe (Günstigkeitsvergleich, "
|
||||
"z. B. § 18 Anrechnung der Rahmen-KVs).")
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', related='company_id.currency_id')
|
||||
|
||||
def _get_kv_gehalt(self, datum=None):
|
||||
"""Katalog-Position (Monatsgehalt) der Version zum Stichtag."""
|
||||
self.ensure_one()
|
||||
return self.kv_gruppe_id._get_gehalt(self.erfahrungsstufe, datum)
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
kv_id = fields.Many2one(
|
||||
related="version_id.kv_id", inherited=True,
|
||||
readonly=False, groups="hr_payroll.group_hr_payroll_user")
|
||||
kv_gruppe_id = fields.Many2one(
|
||||
related="version_id.kv_gruppe_id", inherited=True,
|
||||
readonly=False, groups="hr_payroll.group_hr_payroll_user")
|
||||
erfahrungsstufe = fields.Integer(
|
||||
related="version_id.erfahrungsstufe", inherited=True,
|
||||
readonly=False, groups="hr_payroll.group_hr_payroll_user")
|
||||
ueberzahlung = fields.Monetary(
|
||||
related="version_id.ueberzahlung", inherited=True,
|
||||
readonly=False, groups="hr_payroll.group_hr_payroll_user",
|
||||
currency_field='currency_id')
|
||||
@@ -0,0 +1,225 @@
|
||||
# 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,
|
||||
)
|
||||
Reference in New Issue
Block a user