mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
[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:
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract Bgld. GemBG 2014 pay scale tables (Entlohnungsschemata) into
|
||||
module CSV seed data for l10n_at_gemeinde_payroll.
|
||||
|
||||
Source of truth: consolidated GemBG text (RIS LrBgld GesNr 20001002,
|
||||
Fassung 09.09.2026, incl. Bezügeanpassung 2026 / RV 0715/XXIII. GP).
|
||||
Run from the repository root:
|
||||
|
||||
python3 personalverrechnung/tools/extract_gembg_katalog.py
|
||||
|
||||
Outputs:
|
||||
addons/l10n_at_gemeinde_payroll/data/gemeinde.payroll.entlohnungsgruppe.csv
|
||||
addons/l10n_at_gemeinde_payroll/data/gemeinde.payroll.entlohnungsstufe.csv
|
||||
plus a protocol printed to stdout. Effective dates: tables amended by
|
||||
Artikel 4 RV 0715 carry the "mit 1. Juli 2026" entry into force; all other
|
||||
tables are dated 2025-07-01 as a documented assumption to be verified
|
||||
against the LGBl in AP0 (see personalverrechnung/IMPLEMENTIERUNGSPLAN-Bgld.md).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SOURCE = REPO / ".firecrawl" / "ris-gbg-konsolidiert.md"
|
||||
TARGET_DIR = REPO / "addons" / "l10n_at_gemeinde_payroll" / "data"
|
||||
GRUPPE_CSV = TARGET_DIR / "gemeinde.payroll.entlohnungsgruppe.csv"
|
||||
STUFE_CSV = TARGET_DIR / "gemeinde.payroll.entlohnungsstufe.csv"
|
||||
|
||||
# Tables amended by Artikel 4 RV 0715 with effect from 1 July 2026.
|
||||
AMENDED_2026 = {"57", "58", "62", "88", "133g", "133i", "133j", "133u",
|
||||
"150c", "151", "151c", "151e", "157m", "158a"}
|
||||
DEFAULT_GUELTIG_AB = "2025-07-01"
|
||||
AMENDED_GUELTIG_AB = "2026-07-01"
|
||||
|
||||
# § 66 Abs 2 GemBG: default 4 years; § 150c Abs 2 and § 150d (Betreuungs-
|
||||
# personen gb1/gb2): 2 years for these groups. To verify in AP0.
|
||||
TWO_YEAR_GROUPS = {"gb1", "gb2", "l2b1", "l3", "gb1a", "gb3"}
|
||||
|
||||
PARAGRAF_RE = re.compile(r"^#### § (\d+[a-z]?)")
|
||||
SECTION_RE = re.compile(r"^#### (Monatsentgelt|Einstufung)(?!.*Ausbildungsphase)")
|
||||
SCHEMA_RE = re.compile(r"Entlohnungsschemas (.+?)(?:Monatsentgelt|$)")
|
||||
GROUP_CELL_RE = re.compile(r"^[a-z][a-z0-9]*$")
|
||||
GLUED_GROUP_RE = re.compile(r"Entlohnungsgruppe\s+([a-z][a-z0-9]*)")
|
||||
|
||||
|
||||
def parse_german_number(raw: str) -> float | None:
|
||||
raw = raw.strip().replace("Euro", "").strip()
|
||||
if not re.match(r"^\d{1,3}(\.\d{3})*,\d{2}$|^\d+,\d{2}$", raw):
|
||||
return None
|
||||
return float(raw.replace(".", "").replace(",", "."))
|
||||
|
||||
|
||||
def cells(row: str) -> list[str]:
|
||||
return [c.strip() for c in row.strip().strip("|").split("|")]
|
||||
|
||||
|
||||
def sanitize(code: str) -> str:
|
||||
return re.sub(r"[^a-z0-9_]", "_", code.lower())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = [line.replace("\xa0", " ")
|
||||
for line in SOURCE.read_text(encoding="utf-8").splitlines()]
|
||||
gruppen: dict[tuple[str, str], dict] = {}
|
||||
stufen: list[dict] = []
|
||||
protocol: list[str] = []
|
||||
paragraf = None
|
||||
schema = None
|
||||
in_monatsentgelt = False
|
||||
current_groups: list[str] = []
|
||||
|
||||
for line in text:
|
||||
m = PARAGRAF_RE.match(line)
|
||||
if m:
|
||||
paragraf = m.group(1)
|
||||
in_monatsentgelt = False
|
||||
current_groups = []
|
||||
continue
|
||||
if SECTION_RE.match(line):
|
||||
in_monatsentgelt = True
|
||||
ms = SCHEMA_RE.search(line)
|
||||
schema = ms.group(1) if ms else (f"§{paragraf}" if paragraf else "unbekannt")
|
||||
current_groups = []
|
||||
continue
|
||||
if not in_monatsentgelt or not line.startswith("|"):
|
||||
continue
|
||||
|
||||
row = cells(line)
|
||||
if row and all(c and GROUP_CELL_RE.match(c) for c in row):
|
||||
current_groups = row # codes on their own row
|
||||
continue
|
||||
if "Entlohnungsgruppe" in line:
|
||||
glued = [m.group(1) for m in
|
||||
(GLUED_GROUP_RE.search(c) for c in row) if m]
|
||||
if glued:
|
||||
current_groups = glued # codes glued into the header row
|
||||
continue
|
||||
if not current_groups:
|
||||
continue
|
||||
if len(row) >= 2 and row[0].isdigit():
|
||||
stufe_nr = int(row[0])
|
||||
values = row[1:]
|
||||
if len(values) < len(current_groups):
|
||||
continue
|
||||
for group, raw in zip(current_groups, values):
|
||||
amount = parse_german_number(raw)
|
||||
if amount is None:
|
||||
protocol.append(
|
||||
f"WARN §{paragraf} {group} Stufe {stufe_nr}: "
|
||||
f"unverständlicher Wert {raw!r}")
|
||||
continue
|
||||
key = (schema, group)
|
||||
if key not in gruppen:
|
||||
gueltig_ab = (AMENDED_GUELTIG_AB if paragraf in AMENDED_2026
|
||||
else DEFAULT_GUELTIG_AB)
|
||||
gruppen[key] = {
|
||||
"code": group,
|
||||
"schema": schema,
|
||||
"paragraph": paragraf or "",
|
||||
"bundesland": "bgld",
|
||||
"vorrueckungszeitraum_jahre": 2 if group in TWO_YEAR_GROUPS else 4,
|
||||
"gueltig_ab": gueltig_ab,
|
||||
"max_stufe": 0,
|
||||
}
|
||||
else:
|
||||
gueltig_ab = gruppen[key]["gueltig_ab"]
|
||||
gruppen[key]["max_stufe"] = max(gruppen[key]["max_stufe"], stufe_nr)
|
||||
stufen.append({
|
||||
"group": group,
|
||||
"schema": schema,
|
||||
"stufe": stufe_nr,
|
||||
"monatsentgelt": f"{amount:.2f}",
|
||||
"gueltig_ab": gueltig_ab,
|
||||
})
|
||||
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with GRUPPE_CSV.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["id", "code", "schema", "paragraph", "bundesland",
|
||||
"vorrueckungszeitraum_jahre"])
|
||||
for (schema_id, group), data in sorted(gruppen.items()):
|
||||
writer.writerow([f"bgld_grp_{sanitize(schema_id)}_{sanitize(group)}",
|
||||
group, schema_id, data["paragraph"],
|
||||
data["bundesland"], data["vorrueckungszeitraum_jahre"]])
|
||||
with STUFE_CSV.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["id", "entlohnungsgruppe_id:id", "stufe",
|
||||
"monatsentgelt", "gueltig_ab"])
|
||||
for s in sorted(stufen, key=lambda r: (r["schema"], r["group"], r["stufe"])):
|
||||
writer.writerow([
|
||||
f"bgld_st_{sanitize(s['schema'])}_{sanitize(s['group'])}_{s['stufe']}",
|
||||
f"l10n_at_gemeinde_payroll.bgld_grp_{sanitize(s['schema'])}_{sanitize(s['group'])}",
|
||||
s["stufe"], s["monatsentgelt"], s["gueltig_ab"],
|
||||
])
|
||||
|
||||
for (schema_id, group), data in sorted(gruppen.items()):
|
||||
protocol.append(
|
||||
f"§{data['paragraph']:>6} | Schema {schema_id:>7} | {group:<6} "
|
||||
f"| {data['max_stufe']:>2} Stufen | ab {data['gueltig_ab']} "
|
||||
f"| Vorrückung {data['vorrueckungszeitraum_jahre']} J.")
|
||||
protocol.append(f"\n{len(gruppen)} Entlohnungsgruppen, {len(stufen)} Stufenwerte.")
|
||||
protocol.append("Annahme: nicht 2026 angepasste Tabellen = Wert 1.7.2025 "
|
||||
"(AP0 gegen LGBl verifizieren).")
|
||||
print("\n".join(protocol))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user