mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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:
@@ -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 1–20
|
||||
self._krankenstand(
|
||||
employee, datetime.date(2026, 10, 1),
|
||||
datetime.date(2026, 10, 20)) # KT 21–40
|
||||
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')]))
|
||||
Reference in New Issue
Block a user