[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:
2026-09-09 22:10:26 +02:00
parent 6e444b088a
commit 67374611c6
16 changed files with 1274 additions and 12 deletions
@@ -0,0 +1,2 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import kv_katalog_import
@@ -0,0 +1,211 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""KV-Katalog-Import (GP1): Neuaufnahme und Jahresupdate.
Tabellenpaste/CSV (Gruppe;Stufe;Gehalt[;Bezeichnung]) wird geparst,
gegen den bestehenden Katalog plausibilisiert (Sprungwarnung > 10 %
nach TASY-Muster; Warnzeilen werden nie automatisch übernommen) und
schreibt nach expliziter Bestätigung **neue ``gueltig_ab``-Versionen**
— historische Werte bleiben unangetastet. Quelle der Tabellen sind die
KV-Library-Volltexte bzw. Kammer-Lohntafeln (Quellenarchitektur des
RUNBOOK).
"""
import re
from odoo import _, fields, models
_BETRAG_MUSTER = re.compile(r'^\d{1,3}(?:\.\d{3})+(?:,\d+)?$')
class L10nAtPayrollKvImportLine(models.TransientModel):
_name = 'l10n.at.payroll.kv.import.line'
_description = 'KV-Katalog-Import: Vorschauzeile'
wizard_id = fields.Many2one(
'l10n.at.payroll.kv.import.wizard', required=True, ondelete='cascade')
gruppe_code = fields.Char(string='Gruppe', required=True)
gruppe_name = fields.Char(string='Gruppenbezeichnung', readonly=True)
stufe = fields.Integer(required=True)
bezeichnung = fields.Char(
string='Stufen-Label',
help="Optionales Stufen-Label der KV-Tabelle, z. B. 'nach 2 VWGJ'.")
alter_wert = fields.Monetary(string='Alter Wert', readonly=True)
neuer_wert = fields.Monetary(string='Neuer Wert', required=True)
currency_id = fields.Many2one(related='wizard_id.currency_id')
uebernehmen = fields.Boolean(default=True)
warnung = fields.Char(
help="Leer = unauffällig; sonst Plausibilitätshinweis. "
"Warnzeilen (Sprung > 10 %) werden nie automatisch "
"übernommen.")
class L10nAtPayrollKvImportWizard(models.TransientModel):
_name = 'l10n.at.payroll.kv.import.wizard'
_description = 'KV-Katalog importieren (Aufnahme/Jahresupdate)'
kv_id = fields.Many2one(
'l10n.at.payroll.kv', string='Kollektivvertrag', required=True)
gueltig_ab = fields.Date(
string='Gültig ab', required=True,
default=lambda self: fields.Date.today().replace(month=1, day=1),
help="Stichtag der neuen Version (z. B. 1. 1. des Jahresgehalts "
"der KV-Runde). Bestehende Werte zu diesem Stichtag werden "
"aktualisiert, historische nie verändert.")
eingabe = fields.Text(
string='Tabellen (CSV)',
help="Eine Zeile je Stufenwert — Semikolon- oder "
"Tabulator-getrennt (Dezimalkomma erlaubt):\n"
"Gruppe;Stufe;Gehalt[;Bezeichnung]\n"
"z. B. III;1;2528,04;im 1. u 2. VWGJ")
line_ids = fields.One2many(
'l10n.at.payroll.kv.import.line', 'wizard_id', string='Vorschau')
protokoll = fields.Text(readonly=True)
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id)
# ------------------------------------------------------------------
# Parsing
# ------------------------------------------------------------------
@staticmethod
def _parse_betrag(text):
"""'2.059,41 €' / '2528.04' / '2 059,41' → float (oder None)."""
text = (text or '').strip().replace('', '').replace('EUR', '')
text = re.sub(r'\s', '', text)
if not text:
return None
if _BETRAG_MUSTER.match(text):
# Österreichische Schreibweise: Punkt = Tausender
text = text.replace('.', '').replace(',', '.')
else:
text = text.replace(',', '.')
try:
return float(text)
except ValueError:
return None
def _trenner(self, zeile):
if ';' in zeile:
return ';'
if '\t' in zeile:
return '\t'
return ','
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_parse(self):
"""Tabellen parsen und Vorschau (inkl. Plausibilität) aufbauen."""
self.ensure_one()
stufen_obj = self.env['l10n.at.payroll.kv.gruppe.stufe']
vortag = fields.Date.subtract(self.gueltig_ab, days=1)
line_vals, protokoll = [], []
for roh in (self.eingabe or '').splitlines():
zeile = roh.strip()
if not zeile or zeile.startswith('#'):
continue
teile = [t.strip() for t in zeile.split(self._trenner(zeile))]
if len(teile) < 3:
protokoll.append(
_('IGNORIERT (zu kurz): %(zeile)s', zeile=zeile))
continue
gruppe_code, stufe_text, betrag_text = teile[:3]
bezeichnung = teile[3] if len(teile) > 3 else ''
try:
stufe = int(stufe_text)
except ValueError:
protokoll.append(
_('IGNORIERT (Stufe keine Zahl): %(zeile)s',
zeile=zeile))
continue
betrag = self._parse_betrag(betrag_text)
if betrag is None:
protokoll.append(
_('IGNORIERT (Betrag unlesbar): %(zeile)s',
zeile=zeile))
continue
gruppe = self.env['l10n.at.payroll.kv.gruppe'].search([
('kv_id', '=', self.kv_id.id),
('code', '=', gruppe_code)], limit=1)
warnung = ''
alter_wert = 0.0
if not gruppe:
warnung = _('Neue Gruppe — wird mit der Übernahme angelegt.')
else:
bestehend = stufen_obj.search([
('gruppe_id', '=', gruppe.id),
('stufe', '=', stufe),
('gueltig_ab', '=', self.gueltig_ab)], limit=1)
if bestehend:
# Version zum Stichtag existiert schon: deren Wert
# wird aktualisiert — Referenz für die Plausibilität.
alter_wert = bestehend.gehalt
warnung = _(
'Bestehende Version vom %(ab)s wird aktualisiert.',
ab=self.gueltig_ab)
else:
alt = gruppe._get_gehalt(stufe, vortag)
alter_wert = alt.gehalt if alt else 0.0
if alter_wert and abs(
betrag - alter_wert) / alter_wert > 0.10:
warnung += ' ' + _(
'Sprung > 10 %% (%(alt)s%(neu)s)',
alt=alter_wert, neu=betrag)
line_vals.append((0, 0, {
'gruppe_code': gruppe_code,
'gruppe_name': gruppe.name or '',
'stufe': stufe,
'bezeichnung': bezeichnung,
'alter_wert': alter_wert,
'neuer_wert': betrag,
'uebernehmen': 'Sprung' not in warnung,
'warnung': warnung,
}))
self.line_ids = [(5, 0, 0)] + line_vals
self.protokoll = '\n'.join(protokoll)
return True
def action_apply(self):
"""Ausgewählte Zeilen als neue ``gueltig_ab``-Versionen schreiben."""
self.ensure_one()
gruppen_obj = self.env['l10n.at.payroll.kv.gruppe']
stufen_obj = self.env['l10n.at.payroll.kv.gruppe.stufe']
geschrieben = 0
for line in self.line_ids.filtered('uebernehmen'):
gruppe = gruppen_obj.search([
('kv_id', '=', self.kv_id.id),
('code', '=', line.gruppe_code)], limit=1)
if not gruppe:
gruppe = gruppen_obj.create({
'kv_id': self.kv_id.id,
'code': line.gruppe_code,
'name': line.gruppe_name or line.gruppe_code,
})
stufe = stufen_obj.search([
('gruppe_id', '=', gruppe.id),
('stufe', '=', line.stufe),
('gueltig_ab', '=', self.gueltig_ab)], limit=1)
werte = {
'gehalt': line.neuer_wert,
'bezeichnung': line.bezeichnung,
}
if stufe:
stufe.write(werte)
else:
stufen_obj.create(dict(werte, gruppe_id=gruppe.id,
stufe=line.stufe,
gueltig_ab=self.gueltig_ab))
geschrieben += 1
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'message': _('%(count)s Stufenwert(e) geschrieben '
'(gültig ab %(ab)s).',
count=geschrieben, ab=self.gueltig_ab),
'next': {'type': 'ir.actions.act_window_close'},
},
}