[IMP] l10n_at_gemeinde_payroll: implement AP1 pay scale model (GemBG Bgld.)

Katalogmodell nach Implementierungsplan AP1 (freigegeben 2026-09-09):

- ``gemeinde.payroll.entlohnungsgruppe`` (Code, Schema, §, Bundesland,
  Vorrückungszeitraum je Gruppe; § 66 Abs 2 GemBG: 4 Jahre, abweichend
  2 Jahre für Betreuungspersonen-/Sondergruppen) und
  ``gemeinde.payroll.entlohnungsstufe`` (Stufe, Monatsentgelt, Stichtag,
  unique je Gruppe/Stufe/Stichtag) mit Stichtags-Lookup-Helfern.
- Seed 2026: 35 Entlohnungsgruppen, 433 Werte (Schemata I/gv, II/gh,
  Ia/bv, IIa/bh, kb, av sowie §-Gruppen 150c/151/151c), Werte aus der
  konsolidierten GemBG-Fassung 09.09.2026 (Wert 1.7.2026 gemäß Artikel 4
  RV 0715/XXIII. GP; 2025er-Stichtagsannahme für nicht 2026 angepasste
  Tabellen als AP0-Verifikationspunkt dokumentiert). Generator mit
  Herkunftsnachweis: personalverrechnung/tools/extract_gembg_katalog.py.
- GemBG-Felder auf der Payroll-Version (hr.version) plus Employee-Spiegel
  nach dem Engine-Muster (related/inherited): Entlohnungsgruppe, Stufe,
  Besoldungsdienstalter, Vorrückungstermin — berechnet nach § 66 Abs 2
  (erster Tag des Monats nach Vollendung weiterer n Jahre BDA), manuell
  überschreibbar für Überleitungsfälle (§§ 157a ff).
- Vorrückungs-Wizard (§ 66 Abs 2): fällige Versionen zum Stichtag,
  Stufenerhöhung mit Höchststufen-Schutz und Ergebnis-Notification —
  bewusst kein stilles Mutieren im Lohnlauf (Dokumentationspflicht).
- Views/Menüs (Personalverrechnung Bgld. unter Payroll), Security auf
  hr_payroll-Gruppen, Employee-Form-Anbindung in payroll_group.
- Tests (Rechenfälle AP1): Seed-Werte inkl. Quellenanomalie kb1a/6,
  Stichtags-Lookup, Jahresversionierung, Vorrückungstermin (BDA 15.03.2020
  -> 1.4.2028), Wizard-Anwendung mit Höchststufen-Skip.

Statische Validierung grün (py_compile, XML, Manifest, CSV-Integrität);
Runtime-Testlauf auf dem Dev-Host (gem360_dev) ausständig, wie im
Implementierungsplan M2 vorgesehen.
This commit is contained in:
2026-09-09 11:55:15 +02:00
parent 4e79c8b8b1
commit 30270f9877
15 changed files with 1159 additions and 10 deletions
@@ -1,3 +1,3 @@
# Models are implemented as part of the approved implementation plan;
# see personalverrechnung/PLAN-payroll-bgld.md and
# personalverrechnung/RECHTSQUELLEN-Bgld.md.
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from . import entlohnung
from . import hr_version_bgld
@@ -0,0 +1,98 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from odoo import _, api, fields, models
class GemeindePayrollEntlohnungsgruppe(models.Model):
"""Entlohnungsgruppe des Bgld. GemBG 2014 (z. B. bv1, kb2, av1).
Die Entlohnungsstruktur des GemBG ist schemenbasiert (Entlohnungs-
schemata I, II, Ia, IIa, kb, av sowie Sondergruppen je §). Eine Gruppe
gehört zu genau einem Schema; die Monatsentgelte je Stufe stehen auf
``gemeinde.payroll.entlohnungsstufe`` (Jahreswerte aus den Bgld.
Bezügeanpassungs-Sammelgesetzen, siehe personalverrechnung/).
"""
_name = 'gemeinde.payroll.entlohnungsgruppe'
_description = 'GemBG Entlohnungsgruppe (Bgld.)'
_order = 'bundesland, schema, code'
code = fields.Char(required=True, index=True)
schema = fields.Char(
required=True, index=True,
help="Entlohnungsschema gemäß GemBG (z. B. I, II, Ia, IIa, kb, av "
"oder §-Kennung für Sondergruppen).")
paragraph = fields.Char(
help="Paragraf der Monatsentgelt-Tabelle im Bgld. GemBG 2014.")
bundesland = fields.Selection(
[('bgld', 'Burgenland')], required=True, default='bgld', index=True)
vorrueckungszeitraum_jahre = fields.Integer(
string='Vorrückungszeitraum (Jahre)', required=True, default=4,
help="§ 66 Abs 2 GemBG: 4 Jahre; abweichend 2 Jahre für bestimmte "
"Gruppen (§ 150c Abs 2, Betreuungspersonen nach § 150d ff).")
active = fields.Boolean(default=True)
entlohnungsstufe_ids = fields.One2many(
'gemeinde.payroll.entlohnungsstufe', 'entlohnungsgruppe_id',
string='Entlohnungsstufen')
_unique_group = models.Constraint(
'unique (bundesland, schema, code)',
"Eine Entlohnungsgruppe existiert je Schema und Bundesland nur einmal.")
@api.depends('code', 'schema')
def _compute_display_name(self):
for gruppe in self:
gruppe.display_name = f"{gruppe.code} ({gruppe.schema})"
def _get_entgelt(self, stufe, datum=None):
"""Liefert die Entlohnungsstufen-Position zum Stichtag (oder leer)."""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
return self.env['gemeinde.payroll.entlohnungsstufe'].search(
[('entlohnungsgruppe_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 Monatsentgelt zum Stichtag."""
self.ensure_one()
datum = datum or fields.Date.context_today(self)
stufe = self.env['gemeinde.payroll.entlohnungsstufe'].search(
[('entlohnungsgruppe_id', '=', self.id),
('gueltig_ab', '<=', datum)],
order='stufe desc', limit=1)
return stufe.stufe or 0
class GemeindePayrollEntlohnungsstufe(models.Model):
"""Monatsentgelt einer Entlohnungsgruppe in einer Stufe ab einem
Stichtag (Jahresversionen aus den Bezügeanpassungs-Tabellen)."""
_name = 'gemeinde.payroll.entlohnungsstufe'
_description = 'GemBG Monatsentgelt je Entlohnungsstufe (Bgld.)'
_order = 'entlohnungsgruppe_id, stufe, gueltig_ab desc'
_rec_names_search = ['entlohnungsgruppe_id']
entlohnungsgruppe_id = fields.Many2one(
'gemeinde.payroll.entlohnungsgruppe', required=True, index=True,
ondelete='cascade')
stufe = fields.Integer(required=True, index=True)
monatsentgelt = 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_stufe = models.Constraint(
'unique (entlohnungsgruppe_id, stufe, gueltig_ab)',
"Je Entlohnungsgruppe und Stufe gibt es nur einen Wert je Stichtag.")
@api.depends('entlohnungsgruppe_id', 'stufe', 'monatsentgelt', '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.entlohnungsgruppe_id.code,
stufe=stufe.stufe,
betrag=stufe.monatsentgelt,
ab=stufe.gueltig_ab,
)
@@ -0,0 +1,72 @@
# Part of the gem360 onboarding. See LICENSE / project docs for details.
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
class HrVersion(models.Model):
"""GemBG-Besoldungsfelder auf der Payroll-Version des Mitarbeiters.
§ 66 Abs 2 Bgld. GemBG 2014: Die Vorrückung erfolgt mit dem ersten Tag
jenes Monats, der auf die Vollendung weiterer vier Jahre des
Besoldungsdienstalters folgt (Vorrückungstermin); für bestimmte Gruppen
zwei Jahre (§ 150c Abs 2, § 150d ff). Überleitungsfälle (§§ 157a ff)
können einen einmalig vorgezogenen Termin haben — daher ist der
berechnete Termin manuell überschreibbar.
"""
_inherit = 'hr.version'
entlohnungsgruppe_id = fields.Many2one(
'gemeinde.payroll.entlohnungsgruppe', index=True,
groups="hr_payroll.group_hr_payroll_user", tracking=True)
entlohnungsstufe = fields.Integer(
groups="hr_payroll.group_hr_payroll_user", tracking=True)
besoldungsdienstalter = fields.Date(
string='Besoldungsdienstalter (ab)',
groups="hr_payroll.group_hr_payroll_user", tracking=True)
vorrueckungstermin = fields.Date(
compute='_compute_vorrueckungstermin', store=True, readonly=False,
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help="Vorschlag nach § 66 Abs 2 GemBG (BDA + Vorrückungszeitraum); "
"bei Überleitungsfällen manuell korrigierbar.")
@api.depends('entlohnungsgruppe_id', 'besoldungsdienstalter')
def _compute_vorrueckungstermin(self):
for version in self:
bda = version.besoldungsdienstalter
gruppe = version.entlohnungsgruppe_id
if not bda or not gruppe:
version.vorrueckungstermin = False
continue
period = gruppe.vorrueckungszeitraum_jahre or 4
today = fields.Date.context_today(version)
elapsed_years = relativedelta(today, bda).years
next_maturity = bda + relativedelta(
years=(elapsed_years // period + 1) * period)
version.vorrueckungstermin = (
fields.Date.end_of(next_maturity, 'month')
+ relativedelta(days=1)
)
def _get_katalog_entgelt(self, datum=None):
"""Katalog-Position (Monatsentgelt) der Version zum Stichtag."""
self.ensure_one()
return self.entlohnungsgruppe_id._get_entgelt(
self.entlohnungsstufe, datum)
class HrEmployee(models.Model):
_inherit = 'hr.employee'
entlohnungsgruppe_id = fields.Many2one(
related="version_id.entlohnungsgruppe_id", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
entlohnungsstufe = fields.Integer(
related="version_id.entlohnungsstufe", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
besoldungsdienstalter = fields.Date(
related="version_id.besoldungsdienstalter", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
vorrueckungstermin = fields.Date(
related="version_id.vorrueckungstermin", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")