[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
@@ -7,3 +7,6 @@ from . import test_bv_private
from . import test_account_private
from . import test_meldewesen_private
from . import test_reports_private
from . import test_wien_dag_private
from . import test_einstufung_private
from . import test_efzg_private
@@ -52,10 +52,10 @@ class AccountPrivateTest(TransactionCase):
(RF-BUCH-4: Fallback statt Erfindung)."""
for code in ('6000', '6200', '6225', '6230', '6240', '6310',
'6440', '6500', '6560', '6600', '6601', '6602',
'6660', '6661', '6662'):
'6660', '6661', '6662', '6663'):
if code not in ohne:
self._konto(code, 'expense')
for code in ('3540', '3541', '3542', '3550', '3600'):
for code in ('3540', '3541', '3542', '3550', '3560', '3600'):
self._konto(code, 'liability_current', reconcile=True)
if '3850' not in ohne:
self._konto('3850', 'liability_payable', reconcile=True)
@@ -276,4 +276,33 @@ class AccountPrivateTest(TransactionCase):
'SVDG_KV', 'SVDG_PV', 'SVDG_AV', 'SVDG_UV', 'SVDG_WF',
'SVDG_IESG', 'SVDG_SZ_KV', 'SVDG_SZ_PV', 'SVDG_SZ_AV',
'SVDG_SZ_UV', 'SVDG_SZ_IESG'))
self.assertAlmostEqual(self._saldo(lines, '6500'), dg, places=2)
self.assertAlmostEqual(self._saldo(lines, '6500'), dg, places=2)
# ------------------------------------------------------------------
# AP2d: Buchungsbeleg-Report am Lohnlauf
# ------------------------------------------------------------------
def test_05_buchungsbeleg_report(self):
"""Buchungsbeleg am Lohnlauf: Konto/Belegtext/Soll/Haben der
FI-Buchung, Summen balanciert (Soll = Haben)."""
self._kontenplan()
self._hook()
employee = self._employee()
slip = self._slip(employee, 11, confirm=True)
run = self.env['hr.payslip.run'].create({
'name': 'Beleg-Lauf 11/2026',
'date_start': date(2026, 11, 1),
'date_end': date(2026, 11, 30),
})
slip.payslip_run_id = run.id
werte = self.env[
'report.l10n_at_hr_payroll_private.report_buchungsbeleg'
]._get_report_values(run.ids)
self.assertEqual(len(werte['laeufe']), 1)
lauf = werte['laeufe'][0]
self.assertTrue(lauf['zeilen'])
self.assertAlmostEqual(lauf['summe_soll'], lauf['summe_haben'],
places=2)
konten = {z.account_id.code for z in lauf['zeilen']}
self.assertIn('6200', konten) # Gehälter (Debit)
self.assertIn('3850', konten) # NET-Verbindlichkeit
self.assertIn('3600', konten) # SV-Verrechnung
@@ -0,0 +1,232 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP4: EFZG/Teilentgelt — Phasenmaschine (Voll/Halb/ÖGK),
Kontingent-Zusammenrechnung je Arbeitsjahr, Stufen, AU-Sonderkontingent,
fiktive BV (§ 7 Abs 3 BMSVG). Rechenfälle: docs/rechenfaelle/efzg.md."""
import datetime
from odoo import SUPERUSER_ID
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class EfzgPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT EFZG 40h',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 16.0})
for wd in range(5)
],
})
self.company.resource_calendar_id = self.calendar
self.krankenstand_typ = self.env.ref(
'l10n_at_hr_payroll_private.leave_type_at_krankenstand')
self.au_typ = self.env.ref(
'l10n_at_hr_payroll_private.leave_type_at_arbeitsunfall')
def _employee(self, eintritt):
employee = self.env['hr.employee'].create({
'name': 'EFZG Test'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'contract_date_start': eintritt,
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
version.generate_work_entries(
datetime.date(2026, 9, 1), datetime.date(2026, 12, 31))
return employee
def _krankenstand(self, employee, von, bis, typ=None):
leave = self.env['hr.leave'].create({
'name': 'Krank %s' % von,
'employee_id': employee.id,
'holiday_status_id': (typ or self.krankenstand_typ).id,
'request_date_from': von,
'request_date_to': bis,
})
leave.with_user(SUPERUSER_ID)._action_validate()
return leave
def _slip(self, employee, monat):
erster = datetime.date(2026, monat, 1)
letzter = (erster.replace(day=28)
+ datetime.timedelta(days=4)).replace(day=1) \
- datetime.timedelta(days=1)
slip = self.env['hr.payslip'].create({
'name': 'EFZG %d/2026' % monat,
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
slip.action_payslip_done()
return slip
def _total(self, slip, code):
return sum(slip.line_ids.filtered(
lambda l: l.code == code).mapped('total'))
def test_01_voll_halb_oegk_uebergang(self):
"""RF-EFZ-1: 1. Arbeitsjahr 6+4 Wochen — September voll,
Oktober Halbphase ab 13. 10., November halb (6 AT) + ÖGK
(4 AT) mit Reduktionslinien."""
employee = self._employee(datetime.date(2026, 7, 1))
self._krankenstand(
employee, datetime.date(2026, 9, 1),
datetime.date(2026, 11, 15))
slip_sep = self._slip(employee, 9)
slip_nov = self._slip(employee, 11)
efz = slip_nov._at_efz_status()
self.assertEqual(efz['stufe_wochen'], 6)
self.assertEqual(efz['halb_wochen'], 4)
self.assertEqual(efz['halb_arbeitstage'], [
datetime.date(2026, 11, 2), datetime.date(2026, 11, 3),
datetime.date(2026, 11, 4), datetime.date(2026, 11, 5),
datetime.date(2026, 11, 6), datetime.date(2026, 11, 9)])
self.assertEqual(efz['oegk_arbeitstage'], [
datetime.date(2026, 11, 10), datetime.date(2026, 11, 11),
datetime.date(2026, 11, 12), datetime.date(2026, 11, 13)])
# Tagesatz 2.059,41 / 21 AT = 98,07 € (die Regeln rechnen
# mit der unreduzierten laufenden Basis — Ergebnis der
# Regelzeilen prüfen)
tagesatz = 2059.41 / 21
self.assertAlmostEqual(self._total(slip_nov, 'TEIL_ENTGELT_HALB'),
-tagesatz * 0.5 * 6, places=2)
self.assertAlmostEqual(
self._total(slip_nov, 'KRANKENTGELT_PHASE'),
-tagesatz * 4, places=2)
# September: zur Gänze Vollphase — keine Reduktion.
efz_sep = slip_sep._at_efz_status()
self.assertEqual(len(efz_sep['voll_arbeitstage']), 22)
self.assertFalse(efz_sep['halb_arbeitstage'])
self.assertFalse(efz_sep['oegk_arbeitstage'])
self.assertFalse(self._total(slip_sep, 'TEIL_ENTGELT_HALB'))
self.assertFalse(self._total(slip_sep, 'KRANKENTGELT_PHASE'))
def test_02_kontingent_wiederholung(self):
"""RF-EFZ-2: Drei Krankenstände im selben Arbeitsjahr zählen
auf dasselbe Kalendertage-Kontingent (42 KT) — der dritte
startet in der Halbphase."""
employee = self._employee(datetime.date(2026, 7, 1))
self._krankenstand(
employee, datetime.date(2026, 9, 1),
datetime.date(2026, 9, 20)) # KT 120
self._krankenstand(
employee, datetime.date(2026, 10, 1),
datetime.date(2026, 10, 20)) # KT 2140
self._krankenstand(
employee, datetime.date(2026, 10, 25),
datetime.date(2026, 11, 15)) # KT 41 ff.
slip_nov = self._slip(employee, 11)
efz = slip_nov._at_efz_status()
# 25./26. 10. (So/Mo) sind KT 41/42 (voll), ab 27. 10. halb
self.assertIn(datetime.date(2026, 11, 2), efz['halb_arbeitstage'])
self.assertFalse(datetime.date(2026, 11, 2)
in efz['oegk_arbeitstage'])
def test_03_stufe_acht_wochen(self):
"""RF-EFZ-3: Ab 1 vollendeten Dienstjahr 8 Wochen — die
Halbphase beginnt erst bei Kalendertag 57."""
employee = self._employee(datetime.date(2024, 7, 1))
self._krankenstand(
employee, datetime.date(2026, 9, 1),
datetime.date(2026, 11, 30))
slip_nov = self._slip(employee, 11)
efz = slip_nov._at_efz_status()
self.assertEqual(efz['stufe_wochen'], 8)
# Voll bis 26. 10. (KT 56), halb ab 27. 10., ÖGK ab 24. 11.
self.assertIn(datetime.date(2026, 11, 2), efz['halb_arbeitstage'])
self.assertIn(datetime.date(2026, 11, 24), efz['oegk_arbeitstage'])
def test_04_au_sonderkontingent(self):
"""RF-EFZ-4: Erschöpfter Krankheits-Topf — Arbeitsunfall
gebührt trotzdem voll (eigener Topf, kein Halbsatz)."""
employee = self._employee(datetime.date(2026, 7, 1))
self._krankenstand(
employee, datetime.date(2026, 9, 1),
datetime.date(2026, 11, 15)) # Topf erschöpft
self._krankenstand(
employee, datetime.date(2026, 11, 16),
datetime.date(2026, 11, 30), typ=self.au_typ)
slip_nov = self._slip(employee, 11)
efz = slip_nov._at_efz_status()
# Krank: 1.6. 11. halb, ab 10. 11. ÖGK; AU-Tage 16.30. 11.
# bleiben VOLL (Sonderkontingent).
for tag in (16, 17, 18, 19, 20):
self.assertIn(datetime.date(2026, 11, tag),
efz['voll_arbeitstage'])
self.assertFalse(datetime.date(2026, 11, 16)
in efz['oegk_arbeitstage'])
def test_05_fiktive_bv_krankengeldphase(self):
"""RF-EFZ-5: Fiktive BG in der Krankengeldphase — 50 % des
Vormonatsentgelts × Halb-/ÖGK-Kalendertage/30 (§ 7 Abs 3
BMSVG); BV-Basis = reduziertes Entgelt + fiktiver Anteil."""
employee = self._employee(datetime.date(2026, 7, 1))
self._slip(employee, 8) # Vormonats-Bezug
self._krankenstand(
employee, datetime.date(2026, 9, 1),
datetime.date(2026, 11, 15))
self._slip(employee, 10)
slip_nov = self._slip(employee, 11)
efz = slip_nov._at_efz_status()
# Episode-Beginn 1. 9. — Vormonat = August (voll entlohnt,
# unverkürzt), nicht der teilvergütete Oktober.
self.assertEqual(efz['episode_beginn'],
datetime.date(2026, 9, 1))
halb_kt = len(efz['halb_kalendertage'])
oegk_kt = len(efz['oegk_kalendertage'])
self.assertEqual(halb_kt, 9) # 1.9. 11.
self.assertEqual(oegk_kt, 6) # 10.15. 11.
# Fiktive BG = 50 % August-Entgelt × 15/30 Kalendertage.
fiktiv = slip_nov._at_bv_fiktiv_krankengeld()
vormonat = slip_nov.env['hr.payslip'].search([
('employee_id', '=', slip_nov.employee_id.id),
('state', 'in', ('validated', 'paid')),
('date_to', '<', efz['episode_beginn']),
], order='date_from desc', limit=1)
self.assertEqual(vormonat.date_from, datetime.date(2026, 8, 1))
self.assertAlmostEqual(fiktiv, 0.5 * vormonat._l10n_at_sv_basis_roh()
* 15 / 30.0, places=2)
# BV-Grundlage = reduziertes laufendes Entgelt + fiktiver
# Anteil (komponentenweise — der November-Lauf trägt
# zusätzlich eine B13-SZ-Position in der sonstigen Grundlage,
# § 6 BMSVG: 1,53 % inkl. SZ).
laufend = slip_nov._l10n_at_sv_basis_roh()
self.assertAlmostEqual(slip_nov._at_bv_basis_laufen(),
laufend + fiktiv, places=2)
self.assertAlmostEqual(self._total(slip_nov, 'BVG'),
(slip_nov._at_bv_basis_laufen()
+ slip_nov._at_bv_basis_sonst()) * 0.0153,
places=2)
# Ohne Krankengeld-Phase wäre die Grundlage um den fiktiven
# Anteil kleiner.
self.assertAlmostEqual(self._total(slip_nov, 'TEIL_ENTGELT_HALB'),
-(2059.41 / 21) * 0.5 * 6, places=2)
def test_06_ohne_krankenstand_keine_wirkung(self):
"""Ohne Abwesenheit: keine Phasen, keine fiktive BG, keine
Teilentgelt-Zeilen."""
employee = self._employee(datetime.date(2026, 7, 1))
slip = self._slip(employee, 11)
self.assertFalse(slip._at_efz_status())
self.assertEqual(slip._at_bv_fiktiv_krankengeld(), 0.0)
self.assertFalse(self._total(slip, 'TEIL_ENTGELT_HALB'))
self.assertFalse(self._total(slip, 'KRANKENTGELT_PHASE'))
@@ -0,0 +1,138 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP9: Vordienstzeiten → Einstufung. Privat: VWGJ-Ermittlung nach
§ 17 Rahmen-KV (Schwellen aus den Stufen-Labels, Vorrückung am
Ersten des Erreichungsmonats), Vorschlag mit manueller Freigabe."""
import datetime
from freezegun import freeze_time
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class EinstufungPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
employee = self.env['hr.employee'].create({
'name': 'Einstufung Test'})
self.version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
self.employee = employee
def test_01_vwgj_schwellen(self):
"""vwgj_ab-Schwellen aus den Stufen-Labels: 0/2/4/…/15 →
Stufenzuordnung je vollendeten VWGJ (Höchststufe gedeckelt)."""
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(0,
datetime.date(2026, 7, 1)),
1)
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(1,
datetime.date(2026, 7, 1)),
1)
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(2,
datetime.date(2026, 7, 1)),
2)
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(4,
datetime.date(2026, 7, 1)),
3)
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(8,
datetime.date(2026, 7, 1)),
5)
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(15,
datetime.date(2026, 7, 1)),
8)
# Höchststufe bleibt auch nach weiteren Jahren
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(20,
datetime.date(2026, 7, 1)),
8)
# Nächste Schwelle über den erreichten Jahren (§ 17 Abs 5)
self.assertEqual(
self.gruppe_ii._get_naechste_vwgj(2,
datetime.date(2026, 7, 1)),
4)
self.assertFalse(self.gruppe_ii._get_naechste_vwgj(
15, datetime.date(2026, 7, 1)))
# Schwellen erst ab Katalog-Gültigkeit
self.assertEqual(
self.gruppe_ii._get_stufe_fuer_vwgj(8,
datetime.date(2025, 12, 31)),
0)
def test_02_vwgj_vorschlag_und_uebernahme(self):
"""Dienstzeit + Vordienstzeiten → Stufenvorschlag; Übernahme
schreibt die Erfahrungsstufe (manueller Verwaltungsakt)."""
with freeze_time('2026-09-12'):
self.version.write({
'contract_date_start': datetime.date(2020, 1, 1),
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'l10n_at_vordienstzeit_monate': 24,
})
# Dienstzeit 80 Monate (2020-01 bis 2026-09) + 24 = 104
# VWGJ-Monate → 8 vollendete Jahre → Stufe 5 (nach 8 VWGJ).
self.assertEqual(self.version.l10n_at_vwgj_monate, 104)
self.assertEqual(self.version.l10n_at_vwgj_stufe_vorschlag, 5)
# Nächste Schwelle 10 VWGJ: erreicht im Monat
# 2020-01 + (120 24) Monate = 2028-01-01 (§ 17 Abs 5).
self.assertEqual(self.version.l10n_at_naechste_vorrueckung,
datetime.date(2028, 1, 1))
self.assertEqual(self.version.erfahrungsstufe, 1)
self.version.action_einstufung_uebernehmen()
self.assertEqual(self.version.erfahrungsstufe, 5)
def test_03_ohne_vordienstzeiten(self):
"""Ohne Vordienstzeiten zählt allein die Dienstzeit; ohne
Gruppen-Schwellen gibt es keinen Vorschlag."""
with freeze_time('2026-09-12'):
self.version.write({
'contract_date_start': datetime.date(2026, 7, 1),
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'l10n_at_vordienstzeit_monate': 0,
})
self.assertEqual(self.version.l10n_at_vwgj_monate, 2)
self.assertEqual(self.version.l10n_at_vwgj_stufe_vorschlag, 1)
# Nächste Schwelle 2 VWGJ: erreicht 2026-07 + 24 Monate
# → 1. 7. 2028 (§ 17 Abs 5).
self.assertEqual(self.version.l10n_at_naechste_vorrueckung,
datetime.date(2028, 7, 1))
# Ohne Gruppe: kein Vorschlag
self.version.kv_gruppe_id = False
self.assertEqual(self.version.l10n_at_vwgj_stufe_vorschlag, 0)
self.assertFalse(self.version.l10n_at_naechste_vorrueckung)
def test_04_vwgj_ohne_schwellen_kein_vorschlag(self):
"""Gruppe ohne gepflegte vwgj_ab (Import ohne Labels) →
Vorschlag 0, manuelle Einstufung bleibt maßgeblich."""
gruppe = self.env['l10n.at.payroll.kv.gruppe'].create({
'kv_id': self.kv2203.id,
'code': 'TEST-NS',
'name': 'Test ohne Schwellen',
'lohn_basis': 'monatlich',
})
self.env['l10n.at.payroll.kv.gruppe.stufe'].create({
'gruppe_id': gruppe.id,
'stufe': 1,
'bezeichnung': 'Erste Stufe',
'gehalt': 2000.0,
'gueltig_ab': datetime.date(2026, 1, 1),
})
with freeze_time('2026-09-12'):
self.version.write({
'contract_date_start': datetime.date(2020, 1, 1),
'kv_gruppe_id': gruppe.id,
'l10n_at_vordienstzeit_monate': 60,
})
self.assertEqual(self.version.l10n_at_vwgj_stufe_vorschlag, 0)
@@ -0,0 +1,179 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP3: Wiener Dienstgeberabgabe (Wr. DAG, „U-Bahn-Steuer") —
Wochenlogik, Befreiungen § 3, Regel, Fristen. Rechtsgrundlage RIS LrW
GesNr 20000133 (Fassung 12. 09. 2026, verifiziert)."""
import datetime
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class WienDagPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_beitragskontonummer': '1412345678',
'l10n_at_wien_dag': True,
})
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT Wien-DAG 40h',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 16.0})
for wd in range(5)
],
})
self.company.resource_calendar_id = self.calendar
employee = self.env['hr.employee'].create({
'name': 'Wien-DAG Test', 'birthday': '1990-05-15'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'contract_date_start': datetime.date(2020, 1, 1),
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
self.employee = employee
self.version = version
def _slip(self, monat, jahr=2026):
erster = datetime.date(jahr, monat, 1)
letzter = (erster.replace(day=28)
+ datetime.timedelta(days=4)).replace(day=1) \
- datetime.timedelta(days=1)
slip = self.env['hr.payslip'].create({
'name': 'Wien-DAG %d/%d' % (monat, jahr),
'employee_id': self.employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
return slip
def _line_total(self, slip, code):
return sum(slip.line_ids.filtered(
lambda l: l.code == code).mapped('total'))
def test_01_voller_monat_fuenf_wochen(self):
"""November 2026: Montage 2./9./16./23./30. → 5 angefangene
Wochen × 2 € = 10 € Regelbetrag WIEN_DAG."""
slip = self._slip(11)
ergebnis = slip._at_wien_dag()
self.assertEqual(ergebnis['wochen'], 5)
self.assertAlmostEqual(ergebnis['betrag'], 10.0, places=2)
self.assertAlmostEqual(self._line_total(slip, 'WIEN_DAG'),
10.0, places=2)
def test_02_eintritt_und_wochenlogik(self):
"""Eintritt 15. 11. (Sonntag): Wochen ab 9. 11. mit DV-Bestand
(9./16./23./30.) → 4 × 2 = 8 €."""
self.version.contract_date_start = datetime.date(2026, 11, 15)
slip = self._slip(11)
ergebnis = slip._at_wien_dag()
self.assertEqual(ergebnis['wochen'], 4)
self.assertAlmostEqual(ergebnis['betrag'], 8.0, places=2)
def test_03_befreiungen(self):
"""§ 3: Lehrling, 55. Lebensjahr, ≤ 10 Wochenstunden — je 0 €;
ohne Company-Flag kein Ergebnis."""
# Lehrling
self.version.l10n_at_lehrling = True
slip = self._slip(11)
self.assertEqual(slip._at_wien_dag()['betrag'], 0.0)
self.version.l10n_at_lehrling = False
# 55. Lebensjahr überschritten (Geburtstag 1. 5. 1971 → 55 am
# 1. 5. 2026, vor Monatsende)
self.employee.birthday = '1971-05-01'
slip = self._slip(11)
ergebnis = slip._at_wien_dag()
self.assertEqual(ergebnis['betrag'], 0.0)
self.assertIn('55. Lebensjahr', ergebnis['info'])
self.employee.birthday = '1990-05-15'
# ≤ 10 Wochenstunden (Kalender 2 Tage à 4 h = 8 h)
klein = self.env['resource.calendar'].create({
'name': 'AT gering 8h',
'tz': 'Europe/Vienna',
'hours_per_day': 4,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 12.0})
for wd in range(2)
],
})
self.version.resource_calendar_id = klein
slip = self._slip(11)
ergebnis = slip._at_wien_dag()
self.assertEqual(ergebnis['betrag'], 0.0)
self.assertIn('10 Wochenstunden', ergebnis['info'])
self.version.resource_calendar_id = self.calendar
# Ohne Flag: keine Abgabe
self.company.l10n_at_wien_dag = False
slip = self._slip(11)
self.assertFalse(slip._at_wien_dag())
self.assertFalse(self._line_total(slip, 'WIEN_DAG'))
def test_04_karenz_woche_befreit(self):
"""Eine Karenz-Woche (ATP_KARENZ-Work Entry) senkt die
abgabepflichtigen Wochen (5 → 4)."""
self.version.generate_work_entries(
datetime.date(2026, 11, 1), datetime.date(2026, 11, 30))
karenz_wet = self.env['hr.work.entry.type'].search([
('code', '=', 'ATP_KARENZ')])
karenz_typ = self.env.ref(
'l10n_at_hr_payroll_private.leave_type_at_karenz')
leave = self.env['hr.leave'].create({
'name': 'Karenz Test',
'employee_id': self.employee.id,
'holiday_status_id': karenz_typ.id,
'request_date_from': datetime.date(2026, 11, 16),
'request_date_to': datetime.date(2026, 11, 20),
})
from odoo import SUPERUSER_ID
leave.with_user(SUPERUSER_ID)._action_validate()
slip = self._slip(11)
ergebnis = slip._at_wien_dag()
self.assertEqual(ergebnis['wochen'], 4)
self.assertAlmostEqual(ergebnis['betrag'], 8.0, places=2)
self.assertIn('Karenz', ergebnis['info'])
def test_05_fristenkalender(self):
"""Cron erzeugt wien_dag-Fristen nur für Flag-Companies: 15.
des Folgemonats monatlich, 31. 3. Jahreserklärung."""
fristen = self.env['l10n.at.payroll.frist']
heute = datetime.date.today()
fristen._cron_fristen_erzeugen()
monat_frist = fristen.search([
('company_id', '=', self.company.id),
('fristart', '=', 'wien_dag')], limit=1)
self.assertTrue(monat_frist)
self.assertEqual(monat_frist.deadline.day, 15)
# Jahresfrist entsteht im Cron nur am 1. Jänner — direkter
# Aufruf der Sicherstellung prüft die Art selbst:
fristen._sicherstellen(
self.company, 'wien_dag_jahres', heute.year - 1, None,
datetime.date(heute.year, 3, 31))
self.assertTrue(fristen.search([
('company_id', '=', self.company.id),
('fristart', '=', 'wien_dag_jahres')]))
# Ohne Flag: keine Wien-Fristen
self.company.l10n_at_wien_dag = False
fristen.search([
('company_id', '=', self.company.id),
('fristart', 'like', 'wien_dag')]).unlink()
fristen._cron_fristen_erzeugen()
self.assertFalse(fristen.search([
('company_id', '=', self.company.id),
('fristart', 'like', 'wien_dag')]))