[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
@@ -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)}