[IMP] l10n_at_hr_payroll(_private): Meldewesen-Reste (AP17) und Status-Sonderfälle (AP18/AP19)

AP17 — Lohnnachweis L17, Finanz-Mitteilungen § 109a/§ 109b, GfB-Jahreszahlung:
- L17 (DM-ORG E.24, BEST LF, SART A1/1100, Version 28): je
  Lohnzahlungszeitraum je DN aus den GP3-Kennzahlen-Flags; Prüfkatalog-
  Semantik (KZ 350 = Brutto gesamt, 351/352/354 Teilbeträge) und formale
  Prüfungen (350-Formel, 357/347-Caps, aliquotierte 354/HOPA-Caps);
  ODL-PDF-Extraktion der Kapitel E.24/E.16/E.26 (txt feldversetzt)
- Finanz-Mitteilungen § 109a (W1, E.16) und § 109b (B1, E.26): Modelle
  je Leistungserbringer/Auftragnehmer, Schwellen als hr.rule.parameter
  (450/900 — § 109a-VO; 100.000 — § 109b Abs 4 lit a, RIS NOR40263383),
  AL31/32/33, LKZ1-7, UID, bis 3 maßgebliche natürliche Personen,
  KORR K/S; Unter-Schwellen-Zeilen entfallen mit Protokollhinweis
- mBGM: geringfügig Beschäftigte (BSGR B010/B030) — T01 § 53a-UV-
  Pauschale + V01 BMSVG 1,53 %, kein T02 (GfB-SZ beitragsfrei,
  § 49 Abs 3); JAGB-Pakettrennung für die jährliche Abrechnung
  (§ 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG, DM-ORG D.52) mit
  2,5 vH-Zuschlag (Z04) in der GfB-Jahresabrechnung (Fälligkeit
  15. 1. Folgejahr)
- Fristenkalender: L17- und § 109b-Fristarten (Ende Februar;
  § 109a/§ 109b EStG und § 58 Abs 8 ASVG RIS-verifiziert)

AP18 — Schwerarbeit/NSchG (Beschaffungsgate erledigt, RIS):
- NSchG (RIS 10008502, BGBl 1981/354) Art XI Abs 3 (2 vH Stammnorm,
  3,80 % via Sistierung) und Abs 6 (Nachtschwerarbeitsmonat: 6-Tage-
  Kriterium, Rückblick-Fenster 12/18, Randmonat); ASchV (20004642)
  § 1/§ 4/§ 5; DM-ORG E.22.2-Erstellvorschriften (Z 1 analog NSchG,
  Z 2/4/5 ≥ 15 Tage, Z 3/GfB nicht melden)
- Kern: Schwerarbeitsmeldung (BEST SM, SART 65/66, Länge 800,
  Tätigkeitsblöcke 26 × 10) mit Altersfilter- und Erstellvorschrift-
  Prüfungen
- Privat: Nachtschwerarbeitsmonat-Zähler (WET ATP_NACHTSCHWER,
  Art-XI-Abs-6-Fenster inkl. Randmonat-Proxy) als stored Flag am Lauf

AP19 — Status- und Verhinderungs-Sonderfälle:
- hr.version-Status (Organ ≤/≥ 25 %, lb-vst-03): Organ ≥ 25 % — kein
  SV-Verhältnis (SVDN/SVDG 0 via SV-Satz-Override, mBGM-Ausschluss,
  LSt läuft weiter); Ferialpraktikanten-Flag (dokumentativ — BMSVG § 6
  nennt keinen Ausschluss, Entgelt-/SV-Logik bleibt normal)
- Präsenz-/Ausbildungs-/Zivildienst-Fiktion (§ 7 Abs 1/2 BMSVG):
  fiktive BV-Bemessungsgrundlage 435,90 € (KBGG § 3 Abs 1 idF vor
  BGBl I 53/2016, lb-vor-04) aliquotiert auf ATP_PRD_TAGE
- Dienstverhinderungs-Sonderfälle bleiben dokumentiert offen
  (lb-dvh-01/04, KB-); Feiertags-EFZ ist durch das AP4-WET-Modell
  abgedeckt

Tests: 21 neue (L17-Positionen/Prüfkatalog, W1/B1-Schwellen, GfB-mBGM/
JAGB/Jahresabrechnung, NSchM-Fenster/Randmonat, SM-Fixsatz, Organ-Sperre,
PRD-Fiktion); Suite 252/252 grün auf frischer -i-DB (85 Kern, 18 GemBG,
213 Privat, 10 Dokumente). Dokumentierte Reste: DZR-36-Fenster,
Randmonat-Proxy und UV-Pauschale-vs-mBGM im Parallellauf (GP8),
fallweise mBGM (G3/G4), 2027er-Schwellen (C7).
This commit is contained in:
2026-09-16 19:12:08 +02:00
parent 1df5fb19c4
commit ec33dfacf2
27 changed files with 2736 additions and 64 deletions
@@ -19,3 +19,5 @@ from . import test_reisekosten_private
from . import test_pendler_private
from . import test_endabrechnung_private
from . import test_karenz_private
from . import test_ap17_private
from . import test_ap18_ap19_private
@@ -0,0 +1,132 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP17 privat: mBGM für geringfügig Beschäftigte (BSGR B010/B030 —
§ 53a-UV-Pauschale T01 + BMSVG-Beitrag V01) und die JAGB-Paket-Trennung
für die jährliche Abrechnung (§ 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG,
DM-ORG D.52/E.32.2.2.1) inkl. 2,5 vH-Zuschlag (Z04) in der
GfB-Jahresabrechnung."""
import base64
import datetime
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class Ap17GfbMbgmTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_beitragskontonummer': '1412345678',
'l10n_at_lohnsteuernummer': '123456789',
})
self.category = self.env['hr.salary.rule.category'].create({
'name': 'Test AT AP17', 'code': 'TEST_AT_AP17'})
self.structure_type = self.env[
'hr.payroll.structure.type'].create({
'name': 'Test AT AP17 Type'})
self.struktur_gfb = self.env['hr.payroll.structure'].create({
'name': 'Test AT AP17 GfB',
'code': 'TEST_AT_AP17_GFB',
'type_id': self.structure_type.id,
})
self.env['hr.salary.rule'].create({
'name': 'LFD', 'code': 'LFD',
'struct_id': self.struktur_gfb.id,
'category_id': self.category.id,
'condition_select': 'none',
'l10n_at_bemessung': 'laufend',
'amount_select': 'fix',
'amount_fix': 500.0,
})
employee = self.env['hr.employee'].create({
'name': 'AP17 GfB 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),
'ssnid': '1234150590',
'sex': 'male',
})
self.employee = employee
self.version = version
def _slip(self, monat, jahr=2026, confirm=True):
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': 'AP17 %d/%d' % (monat, jahr),
'employee_id': self.employee.id,
'struct_id': self.struktur_gfb.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
if confirm:
slip.action_payslip_done()
return slip
def _deklaration(self, monat, jahr=2026):
deklaration = self.env['l10n.at.payroll.mbgm'].create({
'company_id': self.company.id,
'beitragsmonat': datetime.date(jahr, monat, 1),
'art': 'MB',
})
deklaration.action_erzeugen()
return deklaration
def test_01_gfb_mbgm_ohne_jagb_flag(self):
"""GfB-Lauf: BSGR B030, T01 = § 53a-UV-Pauschale 6,50 € (VPTA 0),
V01 BV 1,53 %; ohne Company-Flag im normalen Paket (JAGB 'N')."""
slip = self._slip(1)
self.assertTrue(slip.l10n_at_geringfuegig)
deklaration = self._deklaration(1)
self.assertEqual(deklaration.line_ids.bsgr, 'B030')
self.assertFalse(deklaration.line_ids.jagb)
roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
daten = zeilen[2:-1]
paket = daten[0]
self.assertEqual(paket[0:2], 'PS')
self.assertEqual(paket[170], 'N') # JAGB
satzarten = [zeile[0:2] for zeile in daten]
# PS + G1 + T1 + BS AB + V1 T01 (Pauschale) + BS BV + V1 V01
self.assertEqual(satzarten, ['PS', 'G1', 'T1', 'BS', 'V1',
'BS', 'V1'])
v1_t01 = daten[4]
self.assertEqual(v1_t01[20:23], 'T01')
self.assertEqual(v1_t01[24:30], '000000') # VPTA 0 (Pauschale)
self.assertEqual(v1_t01[31:42], '00000000650') # RSUM 6,50 €
def test_02_jagb_paket_trennung(self):
"""Company-Flag: das GfB-mBGM läuft ins JAGB-Paket (JAGB 'J',
REFP-Suffix, eigenes ANZM/GSUM); kein normales Paket, wenn nur
GfB-AN im Monat."""
self.company.l10n_at_gfb_jaehrliche_abrechnung = True
slip = self._slip(2)
deklaration = self._deklaration(2)
self.assertTrue(deklaration.line_ids.jagb)
roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
daten = zeilen[2:-1]
paket = daten[0]
self.assertEqual(paket[170], 'J') # JAGB 'J'
self.assertIn('JAGB', deklaration.protokoll)
self.assertIn('2,5', deklaration.protokoll)
def test_03_bv_jahresabrechnung_gfb(self):
"""Jänner + Februar JAGB-Läufe: Summe der JAGB-Beiträge und
2,5 vH-Zuschlag (§ 6 Abs 2a BMSVG; Z04), Fälligkeit Folgejahr."""
self.company.l10n_at_gfb_jaehrliche_abrechnung = True
self._slip(1)
self._slip(2)
self._deklaration(1)
deklaration_2 = self._deklaration(2)
abrechnung = deklaration_2._l10n_at_bv_jahresabrechnung_gfb(2026)
self.assertEqual(abrechnung['summe'],
sum(deklaration_2.line_ids.mapped('vsum')) * 2)
self.assertAlmostEqual(abrechnung['zuschlag'],
round(abrechnung['summe'] * 0.025, 2))
self.assertAlmostEqual(abrechnung['gesamt'],
round(abrechnung['summe'] * 1.025, 2))
@@ -0,0 +1,198 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP18/AP19-Tests: Nachtschwerarbeitsmonat-Zähler (NSchG Art XI Abs 6
— 6/12/18-Fenster + Randmonat-Proxy), Schwerarbeitsmeldungs-Fixsatz
(E.22, BEST SM, SART 65, Tätigkeitsblöcke, Altersfilter, Erstellvor-
schriften), SV-Sperre Organ ≥ 25 % (lb-vst-03) und die fiktive
BV-Grundlage Präsenz-/Ausbildungs-/Zivildienst (§ 7 Abs 1/2 BMSVG)."""
import base64
import datetime
from odoo import fields
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class Ap18Test(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_beitragskontonummer': '1412345678',
'l10n_at_lohnsteuernummer': '123456789',
})
employee = self.env['hr.employee'].create({
'name': 'AP18 Test', 'birthday': '1970-01-15'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'contract_date_start': datetime.date(2020, 1, 1),
'ssnid': '1234150170',
'sex': 'male',
})
self.employee = employee
self.version = version
def _nachtschwer_wets(self, tage, monat, jahr=2026):
wett = self.env.ref(
'l10n_at_hr_payroll_private.wett_nachtschwer')
erster = datetime.date(jahr, monat, 1)
for tag in range(1, tage + 1):
self.env['hr.work.entry'].create({
'name': 'NSchW %d' % tag,
'employee_id': self.employee.id,
'date': erster.replace(day=tag),
'work_entry_type_id': wett.id,
})
def test_01_nachtschwerarbeitsmonat_fenster(self):
"""Art XI Abs 6: 6 Tage im Monat → NSchM; 2 + 3 (Vormonat 5):
2+5 = 7 < 12 → kein NSchM; 4 + 8 = 12 → NSchM via 12er-Fenster;
4 + 4 + 11 = 19 ≥ 18 → 18er-Fenster."""
self._nachtschwer_wets(6, 3)
slip = self.env['hr.payslip'].create({
'name': 'NSchW 3/2026', 'employee_id': self.employee.id,
'date_from': datetime.date(2026, 3, 1),
'date_to': datetime.date(2026, 3, 31),
})
self.assertTrue(slip.l10n_at_nachtschwerarbeitsmonat)
# 12er-Fenster: März 4 + Februar 8 = 12 → NSchM im April.
self.env['hr.work.entry'].search([]).unlink()
self._nachtschwer_wets(4, 4)
self._nachtschwer_wets(8, 3)
slip_4 = self.env['hr.payslip'].create({
'name': 'NSchW 4/2026', 'employee_id': self.employee.id,
'date_from': datetime.date(2026, 4, 1),
'date_to': datetime.date(2026, 4, 30),
})
self.assertTrue(slip_4.l10n_at_nachtschwerarbeitsmonat)
def test_02_randmonat_pro_rata(self):
"""Randmonat-Proxy: 5 Tage im 15-tägigen Teilmonat —
erforderlich = ceil(6 × 15/31) = 2 → 5 ≥ 2 → NSchM (Proxy ⚠)."""
self._nachtschwer_wets(5, 5)
slip = self.env['hr.payslip'].create({
'name': 'NSchW 5/2026 (Randmonat)',
'employee_id': self.employee.id,
'date_from': datetime.date(2026, 5, 20),
'date_to': datetime.date(2026, 5, 31),
})
self.assertTrue(slip.l10n_at_nachtschwerarbeitsmonat)
def test_03_schwerarbeitsmeldung_fixsatz(self):
"""SART 65 (Länge 800): BKNR/DGNA/VSNR/GEBD/JAHR + Tätigkeits-
blöcke (TART '01' an 502, TVON/TBIS); Altersfilter-Warnung."""
junger = self.env['hr.employee'].create({
'name': 'AP18 Jung', 'birthday': '1995-06-10'})
version_j = self.env['hr.version'].search(
[('employee_id', '=', junger.id)], limit=1)
version_j.write({'ssnid': '1234100695', 'sex': 'male'})
meldung = self.env['l10n.at.payroll.schwerarbeitsmeldung'].create({
'company_id': self.company.id, 'jahr': 2026})
line = self.env[
'l10n.at.payroll.schwerarbeitsmeldung.line'].create({
'meldung_id': meldung.id,
'employee_id': junger.id,
'versicherungsnummer': '1234100695',
'taetigkeit_ids': [
(0, 0, {'ziffer': 1,
'von': datetime.date(2026, 1, 1),
'bis': datetime.date(2026, 12, 31)}),
],
})
meldung.action_erzeugen()
self.assertEqual(meldung.state, 'done')
roh = base64.b64decode(meldung.datei).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
sm_satz = zeilen[2]
self.assertEqual(len(sm_satz), 800)
self.assertEqual(sm_satz[0:2], '65')
self.assertEqual(sm_satz[339:349], '1234100695') # VSNR pos 340
self.assertEqual(sm_satz[501:503], '01') # TART_1
self.assertEqual(sm_satz[503:507], '0101') # TVON
self.assertEqual(sm_satz[507:511], '3112') # TBIS
self.assertIn('Altersgrenze', meldung.protokoll)
@tagged('post_install', '-at_install')
class Ap19Test(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_beitragskontonummer': '1412345678',
'l10n_at_lohnsteuernummer': '123456789',
})
self.category = self.env['hr.salary.rule.category'].create({
'name': 'Test AT AP19', 'code': 'TEST_AT_AP19'})
self.structure_type = self.env[
'hr.payroll.structure.type'].create({
'name': 'Test AT AP19 Type'})
self.struktur = self.env['hr.payroll.structure'].create({
'name': 'Test AT AP19', 'code': 'TEST_AT_AP19',
'type_id': self.structure_type.id})
self.env['hr.salary.rule'].create({
'name': 'LFD', 'code': 'LFD',
'struct_id': self.struktur.id,
'category_id': self.category.id,
'condition_select': 'none',
'l10n_at_bemessung': 'laufend',
'amount_select': 'fix',
'amount_fix': 3000.0})
employee = self.env['hr.employee'].create({
'name': 'AP19 Test', 'birthday': '1980-01-01'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'contract_date_start': datetime.date(2020, 1, 1),
'ssnid': '1234100180',
'sex': 'male'})
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': 'AP19 %d/%d' % (monat, jahr),
'employee_id': self.employee.id,
'struct_id': self.struktur.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
return slip
def test_04_organ_mehr_sv_frei(self):
"""Organ ≥ 25 %: alle SVDN/SVDG 0 (lb-vst-03); mBGM-Lauf weist
den Mitarbeiter mit Protokoll-Hinweis aus."""
self.version.l10n_at_status = 'organ_mehr'
slip = self._slip(1)
slip.action_payslip_done()
self.assertAlmostEqual(slip._l10n_at_svdn_gesamt(), 0.0, places=2)
self.assertAlmostEqual(slip._l10n_at_svdg_gesamt(), 0.0, places=2)
deklaration = self.env['l10n.at.payroll.mbgm'].create({
'company_id': self.company.id,
'beitragsmonat': datetime.date(2026, 1, 1),
'art': 'MB'})
deklaration.action_erzeugen()
self.assertFalse(deklaration.line_ids)
self.assertIn('kein SV', deklaration.protokoll)
# LSt läuft weiter (Bemessungsgrundlage ohne SV-Sperre).
self.assertGreater(
slip._l10n_at_bemessung_laufen(), 0.0)
def test_04b_prasenzenzdienst_fiktion(self):
"""§ 7 Abs 1/2 BMSVG: fiktive BV-BG = 435,90 € × Tage/Monatstage
(ATP_PRD_TAGE) zusätzlich zur Basis."""
slip = self._slip(3)
slip.input_line_ids = [(0, 0, {
'input_type_id': self.env.ref(
'l10n_at_hr_payroll_private.input_type_atp_prd_tage').id,
'amount': 20.0})]
basis = slip._at_bv_basis_laufen()
fiktiv = slip._at_bv_fiktiv_prasenzenzdienst()
self.assertAlmostEqual(fiktiv, 435.90 * 20 / 31.0, places=2)
self.assertAlmostEqual(basis, 3000.0 + fiktiv, places=2)