# 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, )