[IMP] l10n_at_hr_payroll_private: Wr. DAG, Buchungsbeleg, VWGJ-Einstufung, EFZG-Teilentgelt mit fiktiver BV (AP2d+AP3+AP9+AP4)

AP3 — Wiener Dienstgeberabgabe (U-Bahn-Steuer, RIS LrW GesNr
20000133 verifiziert): Regel WIEN_DAG (Kopfpauschale 2 Euro je DN
und angefangene Woche, Wochenlogik Montag-im-Monat, Befreiungen
Lehrling/55+/<=10 WStd/Karenz-Woche), Company-Flag-Verwertung,
Parameter at_wien_dag_satz, FI 6663/3560 (Referenz-Rest)

AP2d — Buchungsbeleg-Report am Lohnlauf (Konto/Belegtext/Soll/Haben
der FI-Buchung, balancierte Summen; test_account_private-Erweiterung)

AP9 — Vordienstzeiten -> Einstufung: Stufen-Schwelle vwgj_ab an
l10n.at.payroll.kv.gruppe.stufe (aus Labels 'nach N VWGJ'; -1 =
nicht gepflegt, ORM liest Integer-NULL als 0), CSV-Seed,
_get_stufe_fuer_vwgj/_get_naechste_vwgj, Vertragsfeld
l10n_at_vordienstzeit_monate + VWGJ-Vorschlag + Vorrueckungstermin
(S 17 Abs 5 Rahmen-KV, Erster des Erreichungsmonats) + Uebernahme-
Button (manueller Verwaltungsakt)

AP4 — EFZG/Teilentgelt (AngG S 8/EFZG S 2 verifiziert, Gleichlauf
6/8/10/12+4 Wochen; lb-krs-07/08): EFZ-Phasenmaschine models/efzg.py
(Kalendertage-Kontingente je Arbeitsjahr, Krank-/AU-Topf getrennt,
AU-Urlaubsart ATP_AU), Regeln TEIL_ENTGELT_HALB/-50 % und
KRANKENTGELT_PHASE/-100 % je Arbeitstag (Tagesatz aus unreduzierter
Basis), fiktive BV-Grundlage S 7 Abs 3 BMSVG im _at_bv_basis_laufen-
Hook (50 % Vormonatsentgelt x Bezugs-KT/30), Parameter at_efz_stufen;
Krankengeldumlage S 62 ASVG = Falsch-Praemise (kein Baustein);
Rechenfaelle docs/rechenfaelle/efzg.md
This commit is contained in:
2026-09-12 23:17:57 +02:00
parent 34e6750fe3
commit 9b4221fcef
22 changed files with 1596 additions and 144 deletions
@@ -9,4 +9,7 @@ from . import account_chart_private
from . import account_posting_private
from . import mbgm
from . import versichertenmeldung
from . import fristen_private
from . import fristen_private
from . import buchungsbeleg
from . import wien_dag
from . import efzg
@@ -213,13 +213,18 @@ class HrPayslip(models.Model):
return 0.0
basis = self._l10n_at_sv_basis_roh(result_rules)
if pflichtig_ab is None or pflichtig_ab <= self.date_from:
return basis
# AP4: fiktive BG während des Krankengeldbezugs (§ 7 Abs 3
# BMSVG) — 50 % des Vormonatsentgelts aliquot nach
# Kalendertagen der Halb-/ÖGK-Phase; nur in beitrags-
# pflichtigen Läufen (Erstfenster-Teilmonate: dokumentierter
# Randfall).
return basis + self._at_bv_fiktiv_krankengeld()
if pflichtig_ab > self.date_to:
# LZP liegt zur Gänze im beitragsfreien Erstfenster.
return 0.0
ende = min(self.date_to,
tage = (min(self.date_to,
self.version_id.contract_date_end or self.date_to)
tage = (ende - pflichtig_ab).days + 1
- pflichtig_ab).days + 1
if tage <= 0:
return 0.0
return basis * tage / max(self._l10n_at_lohnsteuertage(), 1)
@@ -103,6 +103,10 @@ L10N_AT_BUCHUNGSMATRIX = {
'credit': ('3550',)},
'rule_dz': {'debit': ('6662',), 'debit_arbeiter': ('6602',),
'credit': ('3542',)},
# Wr. DAG („U-Bahn-Steuer“): Aufwand 6663 (l10n_at-Template,
# KB lb-lnk-04 verifiziert); Credit 3560 Referenzbuchhaltungs-
# Rest (Stadtkasse Wien — Kandidat, fehlend-Warnung des Hooks).
'rule_wien_dag': {'debit': ('6663',), 'credit': ('3560',)},
}
@@ -0,0 +1,32 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Buchungsbeleg der Lohnbuchhaltung (AP2d): Druck der FI-Buchung
eines Lohnlaufs — die account.move je Lohnlauf (bzw. je Payslip, wenn
nicht gebatcht bucht wird) mit Konto, Belegtext, Soll/Haben und
Summen; Kontrollgrößen stimmen mit dem Monatsjournal überein
(GP6-Matrix, KFS/BW 6)."""
from odoo import models
class L10nAtPayrollBuchungsbeleg(models.AbstractModel):
_name = 'report.l10n_at_hr_payroll_private.report_buchungsbeleg'
_description = 'Buchungsbeleg Lohnlauf (AP2d)'
def _get_report_values(self, docids, data=None):
runs = self.env['hr.payslip.run'].browse(docids)
laeufe = []
for run in runs:
# Batch-Buchung hängt am Lauf, sonst je Payslip-Move
# (hr_payroll_account schreibt move_id je Slip bzw. je Run).
moves = (run.move_id + run.slip_ids.mapped('move_id')) \
.filtered(lambda m: m.state != 'cancel')
zeilen = moves.line_ids.filtered(
lambda l: l.debit or l.credit).sorted(
key=lambda l: (l.account_id.code or '', l.id))
laeufe.append({
'run': run,
'moves': moves,
'zeilen': zeilen,
'summe_soll': sum(zeilen.mapped('debit')),
'summe_haben': sum(zeilen.mapped('credit')),
})
return {'docs': runs, 'laeufe': laeufe}
@@ -0,0 +1,270 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""EFZG/Teilentgelt (AP4) — Entgeltfortzahlung im Krankenstand.
Rechtsgrundlagen (verifiziert 2026-09-12):
- **AngG § 8 Abs 1/2/2a** und **EFZG § 2 Abs 1/3, § 2 Abs 5**
(Gleichlauf seit 1. 7. 2018, BGBl I 153/2017; EFZG RIS GesNr
10008308, AngG lokal — Stufen 6/8/10/12 Wochen je vollendeten
Dienstjahren <1 / 1-14 / 15-24 / ≥25, zuzüglich je 4 Wochen halbes
Entgelt; wiederholte Verhinderungen im selben Arbeitsjahr
zusammengerechnet; AU/BK-Sonderkontingent 8/10 Wochen ohne
Halbsatz, je Anlassfall, unabhängig vom Krankheits-Topf).
- **lb-krs-08/02/07** (Wissensbasis, Layer 2): Arbeitsjahrprinzip,
Kalendertage als Zähleinheit, Stufensprung im laufenden Stand
verlängert; SV: volle EFZ beitragspflichtig, EFZ < 50 %
beitragsfrei (hier: Halbsatz = 50 % → pflichtig); Lohnsteuer:
laufender Bezug; **BMSVG § 7 Abs 3**: während des Krankengeldbezugs
(Halb- und ÖGK-Phase) fiktive BG = 50 % des Entgelts des
Kalendermonats vor dem Versicherungsfall, SZ ausgenommen.
- Krankengeld selbst zahlt die ÖGK (läuft nicht durch den
Dienstgeber-Payslip; lb-krs-02) — der Payroll-Anteil ist die
Entgelt-Reduktion des Dienstgebers, die fiktive BV-Grundlage und
später die Arbeits-/Entgeltbestätigung (AP8).
- „Krankengeldumlage § 62 ASVG" ist eine Falsch-Prämise
(RECHTSQUELLEN-Privat 2026-09-10) — kein Umlage-Baustein.
Zähleinheiten: Kontingente in **Kalendertagen** (lb-krs-08: Praxis),
bezogen aus den validierten Abwesenheiten (hr.leave der AT-Urlaubsarten
Krankenstand/Arbeitsunfall); die **Entgelt-Minderung** je
Arbeitstag aus dem Arbeitseintragssatz (Tagesatz = laufende
Monatsbasis ÷ Arbeitstage des Monats — Entgeltausfallsprinzip).
Feiertage im Krankenstand werden vom Work-Entry-Modell automatisch
nicht als Krankenstandstage erzeugt (Kalender) — die 100 %-
Feiertagsentgelt-Regel lb-krs-08 bleibt damit gewahrt.
"""
from datetime import date, timedelta
from odoo import fields, models
# WET-Codes der Abwesenheits-Töpfe (AP5-Seed).
_KRANK_WET = 'ATP_KRANK'
_AU_WET = 'ATP_AU'
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
l10n_at_efz_info = fields.Char(
string='AT EFZ-Status (Krankenstand)', compute='_compute_efz_info',
help='Phasenstatus des Krankenstands im Abrechnungszeitraum: '
'volle/halbe Entgeltfortzahlung, ÖGK-Krankengeld-Phase, '
'Kontingente und Stufe (AngG § 8 / EFZG § 2).')
def _compute_efz_info(self):
for slip in self:
efz = slip._at_efz_status()
if not efz:
slip.l10n_at_efz_info = ''
continue
slip.l10n_at_efz_info = (
'Stufe %d (%d+%d Wo) · voll %d AT · halb %d AT · '
'ÖGK %d AT' % (
efz['stufe_wochen'], efz['stufe_wochen'],
efz['halb_wochen'], len(efz['voll_arbeitstage']),
len(efz['halb_arbeitstage']),
len(efz['oegk_arbeitstage'])))
# ------------------------------------------------------------------
# EFZ-Phasenbestimmung
# ------------------------------------------------------------------
def _at_efz_leaves(self):
"""Validierte Krankenstand-/AU-Abwesenheiten des AN als
(start, ende, ist_au)-Kalenderspannen."""
self.ensure_one()
typen = self.env['hr.leave.type'].search([
('work_entry_type_id.code', 'in',
(_KRANK_WET, _AU_WET))])
leaves = self.env['hr.leave'].search([
('employee_id', '=', self.employee_id.id),
('holiday_status_id', 'in', typen.ids),
('state', 'in', ('validate', 'validate1')),
])
return [
(leave.request_date_from, leave.request_date_to,
leave.holiday_status_id.work_entry_type_id.code == _AU_WET)
for leave in leaves
if leave.request_date_from and leave.request_date_to]
def _at_efz_arbeitsjahr_start(self):
"""Beginn des Arbeitsjahres, das die Abrechnungsperiode
überwiegend enthält (Eintrittsdatum-Rhythmus, lb-krs-08)."""
self.ensure_one()
beginn = self.version_id.contract_date_start
if not beginn:
return None
jahre = beginn.year
while True:
aj_start = date(jahre, beginn.month, beginn.day) \
if not (beginn.month == 2 and beginn.day == 29) \
else date(jahre, 3, 1)
aj_naechstes = date(jahre + 1, aj_start.month, aj_start.day)
if aj_start <= self.date_from < aj_naechstes:
return aj_start
jahre += 1
if jahre > beginn.year + 80:
return None
def _at_efz_status(self):
"""EFZ-Phasen je Abrechnungsperiode: {'stufe_wochen',
'halb_wochen', 'voll_arbeitstage', 'halb_arbeitstage',
'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn',
'info'} — None ohne Krankenstand/AU im Arbeitsjahr.
Kalendertage-Maschine: Die Krankheits- und AU-Tage des
Arbeitsjahres werden in Kalenderreihenfolge den Phasen
zugeteilt (voll bis Stufen-Kontingent, halb zuzüglich
4 Wochen, danach ÖGK); AU ausschließlich voll (kein
Halbsatz, § 8 Abs 2a AngG / § 2 Abs 5 EFZG). Ein
Stufensprung im laufenden Stand verlängert das Kontingent
(Stufe je Tag neu bewertet, lb-krs-08)."""
self.ensure_one()
version = self.version_id
aj_start = self._at_efz_arbeitsjahr_start()
if not aj_start:
return None
endtag = max(self.date_to, aj_start)
krank_tage, au_tage = {}, {}
episode_start = None
for von, bis, ist_au in self._at_efz_leaves():
tag = von
while tag <= bis:
if aj_start <= tag <= endtag:
topf = au_tage if ist_au else krank_tage
topf[tag] = True
tag += timedelta(days=1)
if not krank_tage and not au_tage:
return None
params = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_efz_stufen', self.date_from, raise_if_not_found=False) or {}
stufen = sorted(
(int(jahre), int(wochen))
for jahre, wochen in params.get('stufen', []))
halb_wochen = int(params.get('halb_wochen', 4))
au_wochen = int(params.get('au_wochen', 8))
au2_wochen = int(params.get('au2_wochen', 10))
au_ab_jahre = int(params.get('au_ab_jahre', 15))
def stufe_fuer(dienstjahre, tag_ist_au):
"""Anspruchs-Wochen am Stichtag (vollendete Jahre)."""
wochen = stufen[0][1] if stufen else 6
for jahre, stufe in stufen:
if dienstjahre >= jahre:
wochen = stufe
if tag_ist_au:
wochen = au2_wochen if dienstjahre >= au_ab_jahre \
else au_wochen
return wochen
beginn = version.contract_date_start
phasen = {}
verbraucht_krank_voll = verbraucht_krank_halb = 0
verbraucht_au = 0
for tag in sorted(set(krank_tage) | set(au_tage)):
dienstjahre = (tag.year - beginn.year
- ((tag.month, tag.day) < (beginn.month,
beginn.day)))
if tag in au_tage:
if verbraucht_au < stufe_fuer(dienstjahre, True) * 7:
phasen[tag] = 'voll'
else:
phasen[tag] = 'oegk'
verbraucht_au += 1
continue
wochen = stufe_fuer(dienstjahre, False)
if verbraucht_krank_voll < wochen * 7:
phasen[tag] = 'voll'
verbraucht_krank_voll += 1
elif verbraucht_krank_halb < halb_wochen * 7:
phasen[tag] = 'halb'
verbraucht_krank_halb += 1
else:
phasen[tag] = 'oegk'
# Arbeitstage je Phase im Abrechnungszeitraum (Work Entries)
eintraege = self.env['hr.work.entry'].search([
('employee_id', '=', self.employee_id.id),
('date', '>=', self.date_from),
('date', '<=', self.date_to),
('state', '!=', 'cancelled'),
('work_entry_type_id.code', 'in', (_KRANK_WET, _AU_WET)),
])
voll, halb, oegk = [], [], []
for eintrag in eintraege:
phase = phasen.get(eintrag.date)
if phase == 'voll':
voll.append(eintrag.date)
elif phase == 'halb':
halb.append(eintrag.date)
elif phase == 'oegk':
oegk.append(eintrag.date)
# Episode-Beginn für die fiktive BV (§ 7 Abs 3 BMSVG):
# frühester zusammenhängender Verhinderungsblock (Lücke = neue
# Episode), dessen Phase die Halb-/ÖGK-Phase erreicht.
episode_beginn = None
for tag in sorted(phasen):
if phasen[tag] in ('halb', 'oegk'):
block = tag
while (block - timedelta(days=1)) in phasen:
block = block - timedelta(days=1)
episode_beginn = block
break
dienstjahre = (self.date_from.year - beginn.year
- ((self.date_from.month, self.date_from.day)
< (beginn.month, beginn.day)))
return {
'stufe_wochen': stufe_fuer(dienstjahre, False),
'halb_wochen': halb_wochen,
'voll_arbeitstage': sorted(set(voll)),
'halb_arbeitstage': sorted(set(halb)),
'oegk_arbeitstage': sorted(set(oegk)),
# Kalendertage der Phasen IM ABRECHNUNGSZEITRAUM — Basis
# der fiktiven-BV-Aliquote (monatsscharf).
'oegk_kalendertage': sorted(
tag for tag in phasen
if self.date_from <= tag <= self.date_to
and phasen[tag] == 'oegk'),
'halb_kalendertage': sorted(
tag for tag in phasen
if self.date_from <= tag <= self.date_to
and phasen[tag] == 'halb'),
'episode_beginn': episode_beginn,
}
def _at_efz_tagesatz(self, result_rules=None):
"""Voller Tages-Entgeltsatz des Monats (Entgeltausfalls-
prinzip): laufende Monatsbasis ohne die eigenen
Teilentgelt-Reduktionen ÷ Arbeitstage des Kalendermonats."""
self.ensure_one()
basis = self._l10n_at_sv_basis_roh(result_rules)
for code in ('TEIL_ENTGELT_HALB', 'KRANKENTGELT_PHASE'):
if result_rules is not None and code in result_rules \
and result_rules[code]['total']:
basis -= result_rules[code]['total']
arbeitstage = self._at_arbeitstage(self.date_from, self.date_to)
return basis / max(arbeitstage, 1)
def _at_bv_fiktiv_krankengeld(self):
"""Fiktive BV-Grundlage während des Krankengeldbezugs
(§ 7 Abs 3 BMSVG, lb-krs-07): 50 % des Entgelts des
Kalendermonats vor dem Versicherungsfall (SZ ausgenommen),
aliquot nach Kalendertagen des Bezugs (Halb- + ÖGK-Phase,
30-Tage-Konvention). Bezug = letzter validierter Lauf vor
Episode-Beginn; ohne solchen: der laufende Monat (kurze
Dienstverhältnisse, lb-krs-02)."""
self.ensure_one()
efz = self._at_efz_status()
if not efz or not (efz['halb_kalendertage']
or efz['oegk_kalendertage']):
return 0.0
tage = len(efz['halb_kalendertage']) \
+ len(efz['oegk_kalendertage'])
vormonat = None
if efz['episode_beginn']:
vormonat = self.env['hr.payslip'].search([
('employee_id', '=', self.employee_id.id),
('state', 'in', ('validated', 'paid')),
('date_to', '<', efz['episode_beginn']),
], order='date_from desc', limit=1)
basis_monat = vormonat._l10n_at_sv_basis_roh() if vormonat \
else self._l10n_at_sv_basis_roh()
return 0.5 * basis_monat * tage / 30.0
@@ -3,11 +3,20 @@
hr.employee — Muster ``hr_version_bgld.py`` des GemBG-Moduls.
Die Stufenlogik der Referenz-KVs läuft über Verwendungsgruppenjahre
(VWGJ, Biennal-/Triennalsprünge, § 17 der Rahmen-KVs); die Vorrückung
selbst ist GP2-Thema (Wizard analog GemBG), hier werden die
Vertragsfelder und der Katalog-Lookup bereitgestellt.
(VWGJ, Biennal-/Triennalsprünge, § 17 der Rahmen-KVs — verifiziert
SI-2203: Abs 3 Einreihung, Abs 4 Anzahl der angerechneten VWGJ durch
die Firmenleitung mit Dienstzettel-Mitteilung, Abs 5 Vorrückung am
Ersten des Erreichungsmonats). AP9 ergänzt die Vordienstzeiten:
Die Firma erkennt Vordienstzeiten in Monaten an (Dienstzettel),
daraus leitet der Einstufungs-Vorschlag die Erfahrungsstufe ab
(``vwgj_ab`` der Stufenzeilen) und den nächsten Vorrückungstermin.
Freigabe bewusst manuell (action_einstufung_uebernehmen).
"""
from odoo import fields, models
from datetime import date
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
class HrVersion(models.Model):
@@ -31,6 +40,35 @@ class HrVersion(models.Model):
help="Individuell vereinbartes Monatsgehalt; überschreibt das "
"Kataloggehalt der Gruppe/Stufe (Günstigkeitsvergleich, "
"z. B. § 18 Anrechnung der Rahmen-KVs).")
l10n_at_vordienstzeit_monate = fields.Integer(
string='AT Angerechnete Vordienstzeiten (Monate)',
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help="Von der Firmenleitung anerkannte Vordienstzeiten in "
"Monaten (§ 17 Abs 4 Rahmen-KV: Anzahl der angerechneten "
"Verwendungsgruppenjahre — Dienstzettel-Mitteilungspflicht). "
"Fließen in den Einstufungs-Vorschlag und den nächsten "
"Vorrückungstermin ein. AP9.")
l10n_at_vwgj_monate = fields.Integer(
string='AT VWGJ gesamt (Monate)',
compute='_compute_l10n_at_vwgj',
groups="hr_payroll.group_hr_payroll_user",
help="Verwendungsgruppenjahre-Stand heute: Dienstzeit-Monate "
"seit Vertragsbeginn zuzüglich angerechnener "
"Vordienstzeiten.")
l10n_at_vwgj_stufe_vorschlag = fields.Integer(
string='AT Erfahrungsstufen-Vorschlag (VWGJ)',
compute='_compute_l10n_at_vwgj',
groups="hr_payroll.group_hr_payroll_user",
help="Vorgeschlagene Erfahrungsstufe aus den vwgj_ab-Schwellen "
"der Gruppen-Tabelle (nach N VWGJ); 0 = Schwellen "
"nicht gepflegt → manuelle Einstufung maßgeblich.")
l10n_at_naechste_vorrueckung = fields.Date(
string='AT Nächste VWGJ-Vorrückung',
compute='_compute_l10n_at_vwgj',
groups="hr_payroll.group_hr_payroll_user",
help="§ 17 Abs 5 Rahmen-KV: Erster des Monats, in dem die "
"nächste VWGJ-Schwelle erreicht wird (Vertragsbeginn + "
"Schwelle Vordienstzeiten).")
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
l10n_at_personalgruppe = fields.Selection(
@@ -50,10 +88,73 @@ class HrVersion(models.Model):
self.ensure_one()
return self.kv_gruppe_id._get_gehalt(self.erfahrungsstufe, datum)
# ------------------------------------------------------------------
# AP9: Vordienstzeiten → VWGJ-Einstufungsvorschlag (§ 17 Rahmen-KV)
# ------------------------------------------------------------------
@api.depends('kv_gruppe_id', 'contract_date_start',
'l10n_at_vordienstzeit_monate', 'erfahrungsstufe')
def _compute_l10n_at_vwgj(self):
"""VWGJ-Stand heute (Dienstzeit + Vordienstzeiten), Stufen-
Vorschlag aus den vwgj_ab-Schwellen der Gruppe und der nächste
Vorrückungstermin (Erster des Erreichungsmonats, § 17 Abs 5)."""
heute = fields.Date.context_today(self)
for version in self:
version.l10n_at_vwgj_monate = 0
version.l10n_at_vwgj_stufe_vorschlag = 0
version.l10n_at_naechste_vorrueckung = False
if not (version.kv_gruppe_id
and version.contract_date_start):
continue
delta = relativedelta(heute, version.contract_date_start)
dienstzeit_monate = delta.years * 12 + delta.months
version.l10n_at_vwgj_monate = max(
dienstzeit_monate
+ (version.l10n_at_vordienstzeit_monate or 0), 0)
jahre = version.l10n_at_vwgj_monate // 12
version.l10n_at_vwgj_stufe_vorschlag = \
version.kv_gruppe_id._get_stufe_fuer_vwgj(jahre, heute)
naechste = version.kv_gruppe_id._get_naechste_vwgj(
jahre, heute)
if naechste:
erreichung = version.contract_date_start \
+ relativedelta(
months=naechste * 12
- (version.l10n_at_vordienstzeit_monate or 0))
# § 17 Abs 5: Erster des Monats, in dem die erhöhte
# Anzahl der VWGJ erreicht wird.
version.l10n_at_naechste_vorrueckung = erreichung \
.replace(day=1)
def action_einstufung_uebernehmen(self):
"""Vorschlag als Erfahrungsstufe übernehmen — bewusst ein
expliziter Verwaltungsakt (§ 17 Abs 4: Firmenleitung, mit
Dienstzettel bekanntzugeben); kein automatisches Schreiben."""
for version in self:
if version.l10n_at_vwgj_stufe_vorschlag:
version.erfahrungsstufe = \
version.l10n_at_vwgj_stufe_vorschlag
class HrEmployee(models.Model):
_inherit = 'hr.employee'
l10n_at_vordienstzeit_monate = fields.Integer(
related="version_id.l10n_at_vordienstzeit_monate", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
l10n_at_vwgj_monate = fields.Integer(
related="version_id.l10n_at_vwgj_monate", inherited=True,
groups="hr_payroll.group_hr_payroll_user")
l10n_at_vwgj_stufe_vorschlag = fields.Integer(
related="version_id.l10n_at_vwgj_stufe_vorschlag", inherited=True,
groups="hr_payroll.group_hr_payroll_user")
l10n_at_naechste_vorrueckung = fields.Date(
related="version_id.l10n_at_naechste_vorrueckung", inherited=True,
groups="hr_payroll.group_hr_payroll_user")
def action_einstufung_uebernehmen(self):
"""Delegation an die Version (Button am Mitarbeiterformular)."""
self.mapped('version_id').action_einstufung_uebernehmen()
kv_id = fields.Many2one(
related="version_id.kv_id", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
@@ -176,6 +176,42 @@ class L10nAtPayrollKvGruppe(models.Model):
('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()
@@ -202,6 +238,13 @@ class L10nAtPayrollKvGruppeStufe(models.Model):
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)
@@ -0,0 +1,113 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Wiener Dienstgeberabgabe („U-Bahn-Steuer") — Wr. DAG, RIS LrW
Gesetzesnummer 20000133, geltende Fassung 12. 09. 2026 (verifiziert,
Quelle .firecrawl/ris/wien/WDGAG_*.html; § 9: die Einzahlungen fließen
der Stadt Wien zu und sind zur Errichtung einer Untergrundbahn zu
verwenden):
- **§ 5:** 2 € je Dienstnehmer:in und je angefangene Woche eines
bestehenden Dienstverhältnisses; **§ 2:** Beschäftigungsort Wien
(feste Arbeitsstätte, sonst Wohnsitz des Dienstnehmers; Bau-
ausführungen > 6 Monate).
- **§ 3 Befreiungen:** Gebietskörperschaften; Überschreiten des
55. Lebensjahres; Behinderte/Opferfürsorge/BEinstG; Lehrverhältnisse
(BAG); ≤ 10 Wochenstunden; Hausbesorger; gesetzliche Mutter-
schaftsverbote und Karenzurlaub auf gesetzlichem Anspruch;
Präsenzdienst/Zivildienst.
- **§ 6:** Selbstberechnung — Vormonatsschuld bis 15. des Folgemonats
an die Stadtkasse; Jahreserklärung bis 31. 3. inklusive befreiter
Dienstverhältnisse (Fristenkalender-Arten wien_dag/wien_dag_jahres).
Wochenlogik (Praxis-Annahme, im Parallellauf gegen die Magistrats-
Praxis zu verifizieren): „angefangene Woche" = Kalenderwoche, deren
Montag im Abrechnungsmonat liegt; abgabepflichtig, wenn das Dienst-
verhältnis an mindestens einem Tag der Woche besteht. Karenz-Wochen
(validierte ATP_KARENZ-Work Entries) sind befreit. Nicht abgebildet
(bis AP 7): Mutterschaftsverbote, Präsenzdienst/Zivildienst, Haus-
besorger, Behinderten-Status — manuelle Prüfung, Rest dokumentiert.
"""
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import models
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
def _at_wien_dag(self):
"""Wr. DAG je Beitragsmonat: {'wochen', 'satz', 'betrag',
'info'} — None, wenn die Company nicht erfasst ist; 0-Wochen
bei Befreiung. Kopfpauschale, nicht lohnabhängig (§ 5)."""
self.ensure_one()
if not self.company_id.l10n_at_wien_dag:
return None
version = self.version_id
employee = self.employee_id
monat_start = self.date_from
monat_ende = self.date_to
info = []
# Befreiungen § 3 (monatsweise Bewertung)
if version.l10n_at_lehrling:
return {'wochen': 0, 'satz': 0.0, 'betrag': 0.0,
'info': 'Befreit: Lehrverhältnis (§ 3 lit d).'}
if employee.birthday:
vollendet55 = employee.birthday + relativedelta(years=55)
if vollendet55 <= monat_ende:
return {'wochen': 0, 'satz': 0.0, 'betrag': 0.0,
'info': 'Befreit: 55. Lebensjahr überschritten '
'(§ 3 lit b, %s).' % vollendet55}
kalender = version.resource_calendar_id
wochenstunden = sum(
(att.hour_to or 0.0) - (att.hour_from or 0.0)
for att in kalender.attendance_ids) \
if kalender else 0.0
if wochenstunden <= 10.0:
return {'wochen': 0, 'satz': 0.0, 'betrag': 0.0,
'info': 'Befreit: ≤ 10 Wochenstunden (§ 3 lit e).'}
# Dienstverhältnis-Bestand im Monat (§ 1)
von = max(monat_start,
version.contract_date_start or monat_start)
bis = min(monat_ende,
version.contract_date_end or monat_ende)
if von > bis:
return {'wochen': 0, 'satz': 0.0, 'betrag': 0.0,
'info': 'Kein bestehendes Dienstverhältnis im '
'Abrechnungsmonat.'}
# Karenz-Tage des Monats (§ 3 lit g — Rest via WET ATP_KARENZ)
karenz_tage = set()
if self.env['hr.work.entry']._fields.get('leave_id'):
eintraege = self.env['hr.work.entry'].search([
('employee_id', '=', employee.id),
('date', '>=', monat_start),
('date', '<=', monat_ende),
('state', '!=', 'cancelled'),
('work_entry_type_id.code', '=', 'ATP_KARENZ'),
])
karenz_tage = {e.date for e in eintraege}
# Wochen mit Montag im Monat; abgabepflichtig, wenn das
# Dienstverhältnis an mindestens einem Tag der Woche besteht
# (Schnittmenge [DV] ∩ [Woche] nicht leer).
wochen = 0
tag = monat_start + timedelta(
days=(7 - monat_start.weekday()) % 7)
while tag <= monat_ende:
woche_ende = tag + timedelta(days=6)
if von <= woche_ende and bis >= tag:
karenz_in_woche = any(
tag + timedelta(days=i) in karenz_tage
for i in range((woche_ende - tag).days + 1)
if tag + timedelta(days=i) <= monat_ende)
if karenz_in_woche:
info.append('Karenz-Woche ab %s befreit (§ 3 lit g)'
% tag)
else:
wochen += 1
tag += timedelta(days=7)
satz = float(self.env['hr.rule.parameter']
._get_parameter_from_code(
'at_wien_dag_satz', monat_start,
raise_if_not_found=False) or 0.0)
return {'wochen': wochen, 'satz': satz,
'betrag': wochen * satz, 'info': '; '.join(info)}