mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
ec33dfacf2
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).
216 lines
9.7 KiB
Python
216 lines
9.7 KiB
Python
# Part of the odoo-at-payroll project. License: LGPL-3.
|
|
"""AP17: L17 (A1) und Finanz-Mitteilungen § 109a (W1)/§ 109b (B1) —
|
|
Positionsexakte Satz-Assertions (ODL-Extraktion 2026-09-16, 1-basierte
|
|
DM-ORG-Positionen → 0-basierte String-Indizes), Prüfkatalog-Logik
|
|
(350-Formel, 357/347-Caps, 354-Cap aliquotiert) und Schwellen der
|
|
Mitteilungsläufe."""
|
|
import base64
|
|
import datetime
|
|
import types
|
|
|
|
from odoo.tests import TransactionCase, tagged
|
|
|
|
from odoo.addons.l10n_at_hr_payroll.models.elda import (
|
|
L10N_AT_ELDA_IDTEIL,
|
|
L10nAtEldaSatz,
|
|
)
|
|
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l17 import L17_SATZ
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class L17SatzTest(TransactionCase):
|
|
|
|
def test_01_a1_satz_laenge_positionen(self):
|
|
"""A1: Länge 1100; FSART 'A' an Position 21; BELZ 120; JALZ 128;
|
|
B350 an 281 (10 n, Cent); HOPA an 618; Satzlänge exakt."""
|
|
satz = L10nAtEldaSatz(
|
|
L10N_AT_ELDA_IDTEIL + L17_SATZ, 1100,
|
|
bezeichnung='A1 Test')
|
|
satz.setze('SART', 'A1')
|
|
satz.setze('VSTR', '94')
|
|
satz.setze('FSART', 'A')
|
|
satz.setze('PENS', 'N')
|
|
satz.setze('AVSN', '1234150590')
|
|
satz.setze('BELZ', '0101')
|
|
satz.setze('ENLZ', '0131')
|
|
satz.setze('JALZ', 2026)
|
|
satz.setze('V350', '+')
|
|
satz.setze('B350', 1234.56, cent=True)
|
|
satz.setze('HOTA', 21)
|
|
satz.setze('VHOPA', '+')
|
|
satz.setze('HOPA', 63.0, cent=True)
|
|
roh = satz.render()
|
|
self.assertEqual(len(roh), 1100)
|
|
self.assertEqual(roh[20], 'A') # FSART pos 21
|
|
self.assertEqual(roh[119:123], '0101') # BELZ pos 120
|
|
self.assertEqual(roh[127:131], '2026') # JALZ pos 128
|
|
self.assertEqual(roh[131], 'N') # PENS pos 132
|
|
self.assertEqual(roh[132:142], '1234150590') # AVSN pos 133
|
|
self.assertEqual(roh[279], '+') # V350 pos 280
|
|
self.assertEqual(roh[280:290], '0000123456') # B350 pos 281
|
|
self.assertEqual(roh[616], '+') # VHOPA pos 617
|
|
self.assertEqual(roh[617:627], '0000006300') # HOPA pos 618
|
|
|
|
def test_02_kz_salden_mapping(self):
|
|
"""GP3-Flags → L17-KZs: laufend → 350; sonstig ohne Abs-3/6 →
|
|
350+351; abs3/abs6 → 352; SVDN_* → 357; SVDN_SZ → 347; § 68-
|
|
Freibetrag ÜStd → 354; Homeoffice-Regel → HOPA."""
|
|
deklaration = self.env['l10n.at.payroll.l17'].create({
|
|
'company_id': self.env.company.id, 'jahr': 2026})
|
|
laufend = types.SimpleNamespace(
|
|
l10n_at_bemessung='laufend', l10n_at_s67_abs='keine')
|
|
sz = types.SimpleNamespace(
|
|
l10n_at_bemessung='sonstig', l10n_at_s67_abs='keine')
|
|
abf = types.SimpleNamespace(
|
|
l10n_at_bemessung='sonstig', l10n_at_s67_abs='abs3')
|
|
neutral = types.SimpleNamespace(
|
|
l10n_at_bemessung='neutral', l10n_at_s67_abs='keine')
|
|
slip = types.SimpleNamespace(
|
|
line_ids=[
|
|
types.SimpleNamespace(salary_rule_id=laufend,
|
|
code='GEHALT', total=3000.0),
|
|
types.SimpleNamespace(salary_rule_id=sz,
|
|
code='SZ13', total=3000.0),
|
|
types.SimpleNamespace(salary_rule_id=abf,
|
|
code='ABF_ALT', total=6000.0),
|
|
types.SimpleNamespace(salary_rule_id=neutral,
|
|
code='SVDN_KV', total=100.0),
|
|
types.SimpleNamespace(salary_rule_id=neutral,
|
|
code='SVDN_SZ', total=50.0),
|
|
types.SimpleNamespace(salary_rule_id=laufend,
|
|
code='SB_HOMEOFFICE', total=63.0),
|
|
],
|
|
input_line_ids=[],
|
|
_l10n_at_freibetraege_68=lambda: {'szfn': 0.0,
|
|
'ue_std': 60.0},
|
|
)
|
|
salden, hota = deklaration._l10n_at_kz_salden(slip)
|
|
self.assertEqual(salden['kz350'], 12063.0)
|
|
self.assertEqual(salden['kz351'], 3000.0)
|
|
self.assertEqual(salden['kz352'], 6000.0)
|
|
self.assertEqual(salden['kz354'], 60.0)
|
|
self.assertEqual(salden['kz357'], 100.0)
|
|
self.assertEqual(salden['kz347'], 50.0)
|
|
self.assertEqual(salden['hopa'], 63.0)
|
|
|
|
def test_03_l17_pruefungen_pruefkatalog(self):
|
|
deklaration = self.env['l10n.at.payroll.l17'].create({
|
|
'company_id': self.env.company.id, 'jahr': 2026})
|
|
employee = self.env['hr.employee'].create({
|
|
'name': 'L17 Prüfung', 'birthday': '1990-01-01'})
|
|
line = self.env['l10n.at.payroll.l17.line'].create({
|
|
'l17_id': deklaration.id,
|
|
'employee_id': employee.id,
|
|
'date_from': datetime.date(2026, 1, 1),
|
|
'date_to': datetime.date(2026, 1, 31),
|
|
})
|
|
fehler, warnungen = [], []
|
|
line.write({'kz350': 5000.0, 'kz351': 3000.0, 'kz352': 2500.0})
|
|
line._l10n_at_pruefungen(fehler, warnungen)
|
|
self.assertTrue(any('KZ 350' in f for f in fehler))
|
|
fehler.clear()
|
|
line.write({'kz350': 12000.0, 'kz351': 3000.0, 'kz352': 0.0,
|
|
'kz357': 4900.0})
|
|
line._l10n_at_pruefungen(fehler, warnungen)
|
|
self.assertTrue(any('KZ 357' in f for f in fehler))
|
|
fehler.clear()
|
|
line.write({'kz357': 0.0, 'kz354': 2500.0})
|
|
line._l10n_at_pruefungen(fehler, warnungen)
|
|
self.assertTrue(any('KZ 354' in w for w in warnungen))
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class Mitteilung109Test(TransactionCase):
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.company = self.env.company
|
|
self.company.write({'l10n_at_lohnsteuernummer': '123456789'})
|
|
|
|
def _deklaration(self, art, entgelt):
|
|
deklaration = self.env['l10n.at.payroll.mitteilung109'].create({
|
|
'company_id': self.company.id, 'jahr': 2026, 'art': art})
|
|
werte = {
|
|
'name_partner': 'Testpartner',
|
|
'name': 'Testwerk',
|
|
'steuernummer': '8765432',
|
|
'entgelt': entgelt,
|
|
}
|
|
if art == 'w1':
|
|
werte.update({'art_leistung': '04', 'adresse': 'Werkstraße 1',
|
|
'plz': '1010', 'ort': 'Wien'})
|
|
else:
|
|
werte.update({'al31': True, 'ziellaender': 'DE,CH',
|
|
'adresse': 'Werkstraße 1', 'plz': '1010',
|
|
'ort': 'Wien'})
|
|
deklaration.line_ids = [(0, 0, werte)]
|
|
return deklaration
|
|
|
|
def test_04_w1_satz_und_positionen(self):
|
|
deklaration = self._deklaration('w1', 1200.0)
|
|
deklaration.action_erzeugen()
|
|
line = deklaration.line_ids[0]
|
|
self.assertTrue(line.gemeldet)
|
|
daten = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
|
|
w1_satz = daten.splitlines()[3]
|
|
self.assertEqual(len(w1_satz), 1100)
|
|
self.assertEqual(w1_satz[:2], 'W1')
|
|
self.assertEqual(w1_satz[20], 'W') # FSART pos 21
|
|
self.assertEqual(w1_satz[281], '+') # V341 pos 282
|
|
self.assertEqual(w1_satz[282:292], '0000120000') # B341 pos 283
|
|
self.assertEqual(w1_satz[303:305], '12') # FANRA pos 304
|
|
self.assertEqual(w1_satz[305:312], '3456789') # STNRA pos 306
|
|
|
|
def test_05_w1_schwelle_untermeldung(self):
|
|
deklaration = self._deklaration('w1', 400.0)
|
|
deklaration.action_erzeugen()
|
|
line = deklaration.line_ids[0]
|
|
self.assertFalse(line.gemeldet)
|
|
self.assertIn('Schwellen', deklaration.protokoll)
|
|
self.assertFalse(deklaration.datei)
|
|
|
|
def test_05b_w1_900_eur_kumulativ_grenze(self):
|
|
"""450 € je Leistung, aber 1.000 € kumulativ je LE →
|
|
meldepflichtig trotz je-Leistung-Unterschreitung."""
|
|
deklaration = self._deklaration('w1', 450.0)
|
|
deklaration.line_ids = [
|
|
(0, 0, {'name_partner': 'Testpartner', 'name': 'Testwerk',
|
|
'steuernummer': '8765432', 'art_leistung': '04',
|
|
'entgelt': 550.0}),
|
|
]
|
|
deklaration.action_erzeugen()
|
|
self.assertTrue(deklaration.line_ids[0].gemeldet)
|
|
|
|
def test_06_b1_satz_schwellen_ausnahme(self):
|
|
deklaration = self._deklaration('b1', 120000.0)
|
|
deklaration.action_erzeugen()
|
|
line = deklaration.line_ids[0]
|
|
self.assertTrue(line.gemeldet)
|
|
daten = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
|
|
b1_satz = daten.splitlines()[3]
|
|
self.assertEqual(len(b1_satz), 1100)
|
|
self.assertEqual(b1_satz[:2], 'B1')
|
|
self.assertEqual(b1_satz[20], 'B') # FSART pos 21
|
|
self.assertEqual(b1_satz[281], '+') # V499 pos 282
|
|
self.assertEqual(b1_satz[282:292], '0012000000') # B499 pos 283
|
|
self.assertEqual(b1_satz[360], 'J') # AL31 pos 361
|
|
self.assertEqual(b1_satz[361], 'N') # AL32 pos 362
|
|
self.assertEqual(b1_satz[363:366], 'DE ') # LKZ1 pos 364 (3 a)
|
|
self.assertEqual(b1_satz[366:369], 'CH ') # LKZ2 pos 367 (3 a)
|
|
# Unter der 100.000-€-Schwelle: keine Meldung (§ 109b Abs 4).
|
|
klein = self._deklaration('b1', 50000.0)
|
|
klein.action_erzeugen()
|
|
self.assertFalse(klein.line_ids[0].gemeldet)
|
|
# Ausnahme Steuerabzug § 99: keine Meldung trotz Betrags.
|
|
abzug = self._deklaration('b1', 150000.0)
|
|
abzug.line_ids[0].ausnahme_grund = 'steuerabzug'
|
|
abzug.action_erzeugen()
|
|
self.assertFalse(abzug.line_ids[0].gemeldet)
|
|
|
|
def test_07_fristenkalender_ap17(self):
|
|
from odoo.addons.l10n_at_hr_payroll.models.fristenkalender \
|
|
import FRISTART_DEFINITIONEN
|
|
for art in ('l17', 'mitteilung_109b'):
|
|
self.assertIn(art, FRISTART_DEFINITIONEN)
|
|
self.assertEqual(
|
|
FRISTART_DEFINITIONEN[art]['monat'], 2) |