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,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'},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user