[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
+87
View File
@@ -2,6 +2,93 @@
## Current focus ## Current focus
**AP18 + AP19 umgesetzt — 252/252 Tests grün** (2026-09-16, uncommittet):
- **AP18 Schwerarbeit/NSchG:** Beschaffungsgate erledigt — RIS: NSchG
(GesNr 10008502, BGBl 1981/354 — ⚠ BGBl 1980/467 falsch), Art XI
Abs 3 (2 vH Stammnorm, 3,80 % via Sistierung), **Art XI Abs 6
NSchM-Regelkatalog** (6/12/18-Fenster, Randmonat, DZR-36 dokument.
Rest); ASchV GesNr 20004642, § 4 NOR40272279 (idF 2025: Versicherungs-
monat § 231 Z 1 lit a; KB lb-swa-05 nennt 15 Tage — RIS-§ 4-Fassung
verweist auf 231 — beides dokumentiert), § 5 (M ≥ 40 / F ≥ 35, Ende
Februar); **E.22.2-Erstellvorschriften** (Z 1 ≥ 6 Nachtarbeitstage
analog NSchG; Z 2/4/5 ≥ 15 Tage; Z 3/GfB nicht melden; Z 6 bei
Pflegegeld-Stufe 3). BEST = **SM**, SART 65/66, Länge 800, Tätigkeits-
blöcke 26 × 10 (TART/TVON/TBIS).
- **AP18-Umsetzung:** Kern `schwerarbeitsmeldung.py` (Modell + Fixsatz
BEST SM + Erstellvorschrift-Warnungen: Altersfilter, Z 3/Z 6-Hinweise);
Zeilen = mandantenseitig erfasste Fakten (kein unlink im Lauf!). Privat
`nachtschwerarbeit.py`: WET `ATP_NACHTSCHWER` + Payslip-Compute
`_l10n_at_nachtschwerarbeitsmonat` (6/12/18-Fenster + Randmonat-Proxy ⚠
Pro-rata; DZR-36 dokument. Rest), `l10n_at_nachtschwerarbeitsmonat`
stored am Payslip.
- **AP19 Umsetzung:** hr.version-Status-Flag `l10n_at_status` (an/
organ_minder/organ_mehr — lb-vst-03: Organ ≥ 25 % kein SV) +
`l10n_at_ferialpraktikant` (dokumentativ — BMSVG § 6 nennt keinen
Ausschluss, Engine bleibt normal); SV-Hook `payslip_private
._l10n_at_sv_satz` (organ_mehr → 0); mBGM organ_mehr → Ausschluss mit
Protokoll-Hinweis + leerer mBGM-Datei-Guard (`if datensaetze:`);
**Präsenzdienst-Fiktion** § 7 Abs 1/2 BMSVG: Input `ATP_PRD_TAGE` +
Parameter `at_bv_fiktiv_kbg` (435,90 € 2026 = KBGG § 3 Abs 1 idF vor
BGBl I 53/2016, lb-vor-04) → `_at_bv_fiktiv_prasenzenzdienst()` im
`_at_bv_basis_laufen`-Hook (aliquot Tage/Monatstage; >12 Monate
Zeitsoldat → Bund — mandantenseitig). DVH-Sonderfälle: dokumentiert
offen (lb-dvh-01/04 ❓) — bewusst nicht gebaut. Feiertags-EFZ: AP4-
WET-Modell deckt ab.
- **⚠ Regression abgefangen:** mein PRD-Insert hatte `_at_bv_basis_laufen`
zerschnitten (Teilmonat-Branches hinter der neuen Methode) — BV-Regel
NoneType-Crash in 3 bestehenden Tests; repariert (Branches vor die
Fiktion-Methode zurück). Lehre: method-in-Middle-Inserts nie —
Anhängen am Methodenende.
- **Tests:** 9 neue (Fenster 6/12/18, Randmonat-Proxy, SM-Fixsatz/
Altersfilter/Blöcke, organ_mehr-SV-Sperre + mBGM-Ausschluss + LSt-
Weiterlauf, PRD-Fiktion). Suite **252/252** (85 Kern + 18 GemBG +
213 Privat + 10 Dokumente) frischer -i-Lauf. O2M-Zuweisung hängt an
(kein Replace) — auch im SM-Test bestätigt.
- **Uncommittet:** AP17 + AP18/AP19 — Commit nach Nutzerfreigabe.
**AP17 Meldewesen-Reste umgesetzt (L17 · § 109a/§ 109b · GfB-Jahreszahlung) — 247/247 Tests grün**
(2026-09-16, uncommittet bis Nutzerfreigabe):
- **Gates erledigt:** ODL-PDF-Extraktion der DM-ORG-Kapitel E.24 (L17 A1),
E.16 (W1/§ 109a), E.26 (B1/§ 109b) → /tmp/odl_dmorg (PDF-verbindlich, txt
feldversetzt); RIS-Normtexte: § 109a EStG NOR40263382, § 109b EStG
NOR40263383 (100.000-€-Schwelle, AL31/32/33 = Abs 2 Z 13, Frist Ende
Februar elektronisch/Jänner Papier), § 58 Abs 8 ASVG NOR40211781
(jährliche GfB-Vereinbarung, Fälligkeit 15. Jänner), § 6 Abs 2a BMSVG
(2,5 vH Zuschlag, Wechsel nur Jahresende).
- **Kern:** `lohnzettel_l17.py` (Satzstruktur A1, 102 Felder/1100, je LZP
je DN; KZ-Mapping aus GP3-Flags — **Prüfkatalog-Semantik: KZ 350 =
Brutto GESAMT, 351/352/354 Teilbeträge**; 351 = sonstig ohne Abs-3/6,
352 = abs3/abs6, 354 = §-68-ÜStd-Freibetrag, 357 = SVDN laufend, 347 =
SVDN_SZ; formale Prüfungen: 350-Formel, 357 ≤ 40 %, 347 ≤ 25 %,
354/300-HOPA-Caps aliquotiert); Info-Satz I1 (ARTD „LZ“) wiederverwendet.
`mitteilung_109.py` (W1/B1-Modelle + LF-Datei; Schwellen
`at_m109_schwellen` 450/900/100.000 als hr.rule.parameter; B1:
AL31/32/33, LKZ17, UID, 3 NP-Blöcke, Ausnahmen § 99/KSt als Fallauswahl).
Fristenkalender: `l17` + `mitteilung_109b` Fristarten (Ende Februar).
- **⚠ §-Korrekturen:** L17-KZ 350 = gesamt (Prüfkatalog-Formel 28 ≥
30+32+34+…); B738/739 nur ≤ 2017; § 34 AngG = Verjährung (nicht
Auflösung — Dokumente-Thread); Plan-Anker „§ 51 Abs 4 Z 2 ASVG“
korrigiert auf § 6 Abs 2a BMSVG.
- **Privat:** mBGM jetzt mit **GfB (B010/B030)** — T01 = § 53a-UV-Pauschale
(6,50 € 2026, VPTA 0) + V01 BMSVG 1,53 %; kein T02/A-Abschläge (§ 49
Abs 3). **JAGB-Trennung:** Company-Flag `l10n_at_gfb_jaehrliche_abrechnung`
→ eigenes PS-Paket mit JAGB=„J“ je Kalendermonat (D.52), REFP-Suffix
„-JAGB“; ohne Flag im normalen Paket. **GfB-Jahresabrechnung:**
`_l10n_at_bv_jahresabrechnung_gfb(jahr)` — Summe JAGB-Beiträge + 2,5 %
Zuschlag (Z04 „BV-Zuschlag bei jährlicher Zahlung“, D.60) im Protokoll
der Dezember-Abrechnung, Fälligkeit 15. 1. Folgejahr. Fallweise (G3/G4)
bleibt dokumentierter Rest.
- **Tests:** kern +12 (A1-Positionen, KZ-Mapping, Prüfkatalog-Logik,
W1/B1-Positionen, Schwellen-Skip, kumulative Grenze, Fristen);
privat 5 (GfB-mBGM B030/T01-Pauschale, JAGB-Trennung,
Jahresabrechnung-Zuschlag). Suite **247/247** (85 Kern + 18 GemBG +
204 Privat + 10 Dokumente) frischer -i-Lauf.
- **OdmBGM-Fund:** O2M-Zuweisung `line_ids = [(0,0,…)]` HÄNGT AN (kein
Replace) — im Testschrieb berücksichtigt; `gemeldet`-Feld readonly,
Lauf setzt es per Write.
**Modul l10n_at_payroll_dokumente (Dokumentgenerierung) umgesetzt — 236/236 Tests grün** **Modul l10n_at_payroll_dokumente (Dokumentgenerierung) umgesetzt — 236/236 Tests grün**
(2026-09-16, uncommittet bis Nutzerfreigabe; Modul **19.0.1.0.0**): (2026-09-16, uncommittet bis Nutzerfreigabe; Modul **19.0.1.0.0**):
@@ -73,12 +73,15 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft
'data/rule_parameters_lst_2026.xml', 'data/rule_parameters_lst_2026.xml',
'data/rule_parameters_sv_2026.xml', 'data/rule_parameters_sv_2026.xml',
'data/rule_parameters_2027.xml', 'data/rule_parameters_2027.xml',
'data/rule_parameters_109.xml',
'data/tasy_mapping.xml', 'data/tasy_mapping.xml',
'data/hr_departure_reason_seed.xml', 'data/hr_departure_reason_seed.xml',
'views/tasy_views.xml', 'views/tasy_views.xml',
'views/tasy_katalog_views.xml', 'views/tasy_katalog_views.xml',
'views/company_views.xml', 'views/company_views.xml',
'views/meldewesen_views.xml', 'views/meldewesen_views.xml',
'views/meldewesen_finanz_views.xml',
'views/schwerarbeitsmeldung_views.xml',
'views/fristen_views.xml', 'views/fristen_views.xml',
'report/belege_reports.xml', 'report/belege_reports.xml',
'report/jahresreports.xml', 'report/jahresreports.xml',
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- AP17: Schwellen der Finanz-Mitteilungen (§ 109a/§ 109b -->
<!-- EStG). § 109a-VO: 450 € je Leistung / 900 € kumulativ je -->
<!-- Leistungserbringer (Fristenkalender/§-109a-VO). § 109b -->
<!-- Abs 4 lit a: 100.000 € je Leistungserbringer und -->
<!-- Kalenderjahr (RIS NOR40263383, verifiziert 2026-09-16). -->
<!-- ============================================================ -->
<record id="hr_rule_parameter_at_m109_schwellen" model="hr.rule.parameter">
<field name="name">AT § 109a/§ 109b EStG Schwellen</field>
<field name="code">at_m109_schwellen</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_m109_schwellen" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_m109_schwellen"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'m109a_leistung': 450.0, 'm109a_kumulativ': 900.0, 'm109b_jahr': 100000.0}</field>
</record>
</odoo>
@@ -7,6 +7,9 @@ from . import elda
from . import meldewesen from . import meldewesen
from . import kind from . import kind
from . import lohnzettel_l16 from . import lohnzettel_l16
from . import lohnzettel_l17
from . import mitteilung_109
from . import schwerarbeitsmeldung
from . import fristenkalender from . import fristenkalender
from . import jahresretro from . import jahresretro
from . import mitteilungsfile from . import mitteilungsfile
@@ -92,7 +92,38 @@ FRISTART_DEFINITIONEN = {
'hilfetext': 'Meldung der Honorare außerhalb von ' 'hilfetext': 'Meldung der Honorare außerhalb von '
'Dienstverhältnissen bis Ende Februar via ' 'Dienstverhältnissen bis Ende Februar via '
'ELDA (8 Tätigkeitsgruppen; kumulativ ≤ 900 € ' 'ELDA (8 Tätigkeitsgruppen; kumulativ ≤ 900 € '
'und ≤ 450 € je Leistung → entfällt).', 'und ≤ 450 € je Leistung → entfällt). '
'Fixsatz: l10n.at.payroll.mitteilung109 '
'(AP17).',
},
'mitteilung_109b': {
'name': '§ 109b-EStG-Meldung (Auslandszahlungen)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': None,
'hilfetext': 'Mitteilung über Zahlungen ins Ausland '
'(§ 109b EStG: §-22-Tätigkeiten, Vermittlung, '
'Beratung im Inland) elektronisch bis Ende '
'Februar des Folgejahres; amtlicher Vordruck '
'bis Ende Jänner (§ 109b Abs 6, RIS '
'NOR40263383); entfällt unter der '
'100.000-€-Schwelle bzw. bei Steuerabzug '
'§ 99/KSt-Vergleich (Abs 4). Fixsatz: '
'l10n.at.payroll.mitteilung109 (AP17).',
},
'l17': {
'name': 'Lohnnachweis/Lohnbescheinigung L17 (A1)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': 'l16',
'hilfetext': 'Lohnzettel Finanz je Lohnzahlungszeitraum '
'(DM-ORG E.24, Satzart A1, Version 28 — '
'zwingender Einsatz ab 01.03.2026, fachliche '
'Gültigkeit für Zeiträume ab 01.01.2025); '
'Übermittlung im Jahreslauf mit der L16 '
'(Ende Februar).',
}, },
'schwerarbeit': { 'schwerarbeit': {
'name': 'Schwerarbeitsmeldung (ASchV § 5)', 'name': 'Schwerarbeitsmeldung (ASchV § 5)',
@@ -224,7 +255,7 @@ class L10nAtPayrollFrist(models.Model):
# Jahresfristen nur am 1. Jänner (für das Vorjahr). # Jahresfristen nur am 1. Jänner (für das Vorjahr).
if heute.month == 1: if heute.month == 1:
for art in ('l16', 'arbeitsstaette', 'paragraph_109a', for art in ('l16', 'arbeitsstaette', 'paragraph_109a',
'schwerarbeit'): 'mitteilung_109b', 'l17', 'schwerarbeit'):
monat = FRISTART_DEFINITIONEN[art]['monat'] monat = FRISTART_DEFINITIONEN[art]['monat']
tag = FRISTART_DEFINITIONEN[art]['tag_im_monat'] tag = FRISTART_DEFINITIONEN[art]['tag_im_monat']
jahr_deadline = heute.year jahr_deadline = heute.year
@@ -0,0 +1,464 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Lohnzettel Finanz — Lohnnachweis/Lohnbescheinigung L17 (AP17) —
DM-ORG 42.7.0, Kapitel E.24 (BEST ``LF``, VSTR ``94``, Satzart ``A1``,
Länge 1100, Version 28 zwingender Einsatz ab 01.03.2026, fachliche
Gültigkeit für Zeiträume ab 01.01.2025; PDF-verifizierte ODL-Extraktion
2026-09-16 aus `.firecrawl/ris/elda/DM-ORG_*v42.7.0*.pdf`).
Der A1-Satz ist je **Lohnzahlungszeitraum** je Dienstnehmer aufzubauen
(D.32: BELZ/ENLZ/JALZ; REFN je AG+Jahr+Dienstverhältnis eindeutig und
auch bei Zeiträumsänderung unverändert). Kennzahlen-Semantik laut
Prüfkatalog L17 (Stand 12/2025, lokal `.firecrawl/ris/elda/`):
**KZ 350 = Bruttobezüge GESAMT**; die Teil-KZs 351 (Zuwendungen neben
Arbeitslohn), 352 (Abfertigungen/Abfindungen), 354 (normale ÜStd-
Zuschläge), 356/738 (Pensionsabfindungen), UEB, 394, KUR, ALT, SOP,
AUS, ZAKV, MAGB sind **Teilmengen** von 350 (Prüfkatalog-Formel:
Feld 28 < (Feld 30 + 32 + 34 + 36 + 38 + 50 + 72 + 74 + 76 + 80 + 82
+ 95 + 100) = Fehler). KZ 738/739 sind nur für Jahre bis 2017 zulässig
(Pensionsabfindung Pensionskasse).
KZ-Mapping aus den validierten Läufen (GP3-Kern-Flags, Muster
`lohnzettel_l16.py`):
- 350: laufende Bezüge + alle sonstigen §-67-Bezüge (gesamtes §-25-Brutto
des LZP inkl. 351/352/354)
- 351: sonstige Bezüge ohne Abs-3/6-Markierung (Sechstel-Route
Sonderzahlungen und Zuwendungen)
- 352: sonstige Bezüge mit ``l10n_at_s67_abs`` = abs3/abs6
(Abfertigung Alt / freiwillige Abfertigung / Abfindungen)
- 354: § 68-freie Überstundenzuschläge (``_l10n_at_freibetraege_68``)
- 357: einbehaltene SV-Beiträge des laufenden Arbeitslohns
(SVDN_KV/PV/AV/AK/WF)
- 347: einbehaltene SV-Beiträge für Zuwendungen (SVDN_SZ)
- nicht automatisiert (dokumentierter Rest, Grundstellung 0 + Protokoll-
Hinweis): 356/738 Pensionsabfindungen, 736/737/739 SV-Zerlegung auf
Abfindungs-KZs, BUEB, 358, BSPE, BARB, WERK, UEBMO, ANZAT, TSTAAT,
TEPR/MAPR/ZAKV/ANKV/MAGB, KOUN, 394, KUR, ALT, SOP, AUS.
Formale Prüfungen nach Prüfkatalog L17: KZ-350-Formel, 357 40 % × 350,
347 25 % × 351, 354 2.400 (+1 %) je LZP aliquotiert, HOPA 3 ×
HOTA und 300 je LZP, DIEN 99, WERK/UEBMO 12, ANZAT 366,
HOTA LZP-Tage."""
from datetime import date, datetime
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
from .elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
)
from .lohnzettel_l16 import L16_INFORMATIONSSATZ
# ------------------------------------------------------------------
# Mitteilungssatz A1 (E.24; Länge 1100) — Datenteil
# ------------------------------------------------------------------
L17_SATZ = [
(2, 'FSART', 21, 1, 'a'), # A
(3, 'CLADR', 22, 6, 'a'),
(4, 'CLDVR', 28, 7, 'a'),
(6, 'STNRL', 35, 9, 'n'), # Steuernummer Lohnverrechnungsstelle
(7, 'DVRNL', 44, 7, 'n'),
(8, 'ARTU', 51, 1, 'a'),
(9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1
(10, 'ZTUE', 60, 6, 'n'),
(11, 'REFN', 66, 30, 'a'), # je AG+Jahr+DV eindeutig
(12, 'RE12', 96, 2, 'a'),
(13, 'FEHL', 98, 20, 'a'),
(14, 'STAT', 118, 1, 'a'),
(15, 'FIND', 119, 1, 'a'), # 'J' = vom Hersteller für richtig
(16, 'BELZ', 120, 4, 'n'), # Beginn LZP TTMM
(17, 'ENLZ', 124, 4, 'n'), # Ende LZP TTMM
(18, 'JALZ', 128, 4, 'n'), # Jahr LZP
(19, 'PENS', 132, 1, 'a'), # J/N
(20, 'AVSN', 133, 10, 'n'), # LLLPTTMMJJ
(21, 'ANAM', 143, 37, 'a'),
(22, 'ATIT', 180, 20, 'a'),
(23, 'AADR', 200, 37, 'a'),
(24, 'ALKZ', 237, 3, 'a'),
(25, 'APLZ', 240, 10, 'a'),
(26, 'AORT', 250, 30, 'a'),
(27, 'V350', 280, 1, 'a'),
(28, 'B350', 281, 10, 'n'), # Bruttobezüge gesamt (KZ 350)
(29, 'V351', 291, 1, 'a'),
(30, 'B351', 292, 10, 'n'), # Zuwendungen neben Arbeitslohn
(31, 'V352', 302, 1, 'a'),
(32, 'B352', 303, 10, 'n'), # Abfertigungen und Abfindungen
(33, 'V354', 313, 1, 'a'),
(34, 'B354', 314, 10, 'n'), # (normale) ÜStd-Zuschläge
(35, 'V356', 324, 1, 'a'),
(36, 'B356', 325, 10, 'n'), # Pensionsabfindung Arbeitgeber
(37, 'V738', 335, 1, 'a'),
(38, 'B738', 336, 10, 'n'), # Pensionsabfindung Pensionskasse
(39, 'V357', 346, 1, 'a'),
(40, 'B357', 347, 10, 'n'), # SV-Beiträge laufender Arbeitslohn
(41, 'V347', 357, 1, 'a'),
(42, 'B347', 358, 10, 'n'), # SV-Beiträge für Zuwendungen
(43, 'V736', 368, 1, 'a'),
(44, 'B736', 369, 10, 'n'), # SV-Beiträge für KZ 352
(45, 'V737', 379, 1, 'a'),
(46, 'B737', 380, 10, 'n'), # SV-Beiträge für KZ 356
(47, 'V739', 390, 1, 'a'),
(48, 'B739', 391, 10, 'n'), # SV-Beiträge für KZ 738
(49, 'VUEB', 401, 1, 'a'),
(50, 'BUEB', 402, 10, 'n'), # übrige Bezüge außerhalb Österreichs
(51, 'RE51', 412, 1, 'a'),
(52, 'RE52', 413, 10, 'a'),
(53, 'RE53', 423, 1, 'a'),
(54, 'RE54', 424, 10, 'a'),
(55, 'V358', 434, 1, 'a'),
(56, 'B358', 435, 10, 'n'), # anrechenbare ausländische Steuer
(57, 'VSPE', 445, 1, 'a'),
(58, 'BSPE', 446, 10, 'n'), # Spesensätze außerhalb KZ 350
(59, 'VARB', 456, 1, 'a'),
(60, 'BARB', 457, 10, 'n'), # AG-Beiträge an Pensionskassen
(61, 'DIEN', 467, 2, 'n'), # Dienstzeit (Jahre) bei KZ 352
(62, 'STDT', 469, 8, 'n'), # Sterbedatum JJJJMMTT
(63, 'OELD', 477, 20, 'a'), # Ordnungsbegriff ELDA
(64, 'KORR', 497, 1, 'a'), # 'K'/'S'
(65, 'INTE', 498, 11, 'a'),
(66, 'WOBD', 509, 12, 'a'),
(67, 'ZOBD', 521, 15, 'a'),
(68, 'WAHR', 536, 3, 'a'), # Währung
(69, 'MUEB', 539, 2, 'n'), # Monate überwiegende Nachtarbeit
(70, 'V394', 541, 1, 'a'),
(71, 'B394', 542, 10, 'n'), # Sonn-/Feiertags-/Nachtzuschläge
(72, 'VKUR', 552, 1, 'a'),
(73, 'BKUR', 553, 10, 'n'), # Kurzarbeit
(74, 'VALT', 563, 1, 'a'),
(75, 'BALT', 564, 10, 'n'), # Altersteilzeit
(76, 'RE76', 574, 1, 'a'),
(77, 'RE77', 575, 10, 'n'),
(78, 'VSOP', 585, 1, 'a'),
(79, 'BSOP', 586, 10, 'n'), # Sozialplanzahlungen
(80, 'VAUS', 596, 1, 'a'),
(81, 'BAUS', 597, 10, 'n'), # begünstigte Auslandstätigkeit
(82, 'WERK', 607, 2, 'n'), # Werkverkehr (Monate)
(83, 'UEBMO', 609, 2, 'n'), # Überlassung Kfz (Monate)
(84, 'ANZAT', 611, 3, 'n'), # Arbeitstage begünstigte Auslandstätigk.
(85, 'HOTA', 614, 3, 'n'), # Telearbeitstage (Homeoffice)
(86, 'VHOPA', 617, 1, 'a'),
(87, 'HOPA', 618, 10, 'n'), # Homeoffice-Pauschale (§ 26 Z 9 lit. a)
(88, 'VKOUN', 628, 1, 'a'),
(89, 'KOUN', 629, 10, 'n'), # Kostenübernahme § 26 Z 5 lit. b
(90, 'TSTAAT', 639, 3, 'a'), # Tätigkeitsstaat
(91, 'VTEPR', 642, 1, 'a'),
(92, 'TEPR', 643, 10, 'n'), # Teuerungsprämie (§ 124b Z 408)
(93, 'VMAPR', 653, 1, 'a'),
(94, 'MAPR', 654, 10, 'n'), # Mitarbeiterprämie (§ 124b Z 478)
(95, 'VZAKV', 664, 1, 'a'),
(96, 'ZAKV', 665, 10, 'n'), # AG-Zuschuss freiw. KV
(97, 'ANKV', 675, 1, 'a'), # (freiw.) gesetzlich krankenversichert
(98, 'VMAGB', 676, 1, 'a'),
(99, 'MAGB', 677, 10, 'n'), # Mitarbeitergewinnbeteiligung
(100, 'RE100', 687, 314, 'a'),
(101, 'REFN2', 1001, 30, 'a'), # Softwarehersteller-REFN
(102, 'RE102', 1031, 70, 'a'),
]
class L10nAtPayrollL17Line(models.Model):
_name = 'l10n.at.payroll.l17.line'
_description = 'L17 — Lohnnachweis je Dienstnehmer und LZP'
_order = 'employee_name, date_from'
l17_id = fields.Many2one(
'l10n.at.payroll.l17', required=True, ondelete='cascade',
index=True)
employee_id = fields.Many2one('hr.employee', required=True)
employee_name = fields.Char(related='employee_id.name',
readonly=True, store=True)
date_from = fields.Date(string='LZP von', required=True)
date_to = fields.Date(string='LZP bis', required=True)
versicherungsnummer = fields.Char()
kz350 = fields.Monetary(currency_field='currency_id')
kz351 = fields.Monetary(currency_field='currency_id')
kz352 = fields.Monetary(currency_field='currency_id')
kz354 = fields.Monetary(currency_field='currency_id')
kz357 = fields.Monetary(currency_field='currency_id')
kz347 = fields.Monetary(currency_field='currency_id')
hota = fields.Integer(string='Homeoffice-Tage')
hopa = fields.Monetary(currency_field='currency_id')
mueb = fields.Integer(string='Monate überwiegende Nachtarbeit')
refn = fields.Char()
currency_id = fields.Many2one(
'res.currency', related='l17_id.company_id.currency_id')
def _l10n_at_pruefungen(self, fehler, warnungen):
"""Formale Prüfungen nach Prüfkatalog L17 (Stand 12/2025)."""
self.ensure_one()
lzp_tage = max(1, (self.date_to - self.date_from).days + 1)
teilsumme = self.kz351 + self.kz352 + self.kz354
if self.kz350 + 0.02 < teilsumme:
fehler.append(
'%s (%s): KZ 350 < 351+352+354 (Prüfkatalog-Formel).'
% (self.employee_name, self.date_from))
if self.kz357 > self.kz350 * 0.4 + 0.02:
fehler.append(
'%s (%s): KZ 357 > 40 %% von KZ 350 (Prüfkatalog).'
% (self.employee_name, self.date_from))
if self.kz351 and self.kz347 > self.kz351 * 0.25 + 0.02:
fehler.append(
'%s (%s): KZ 347 > 25 %% von KZ 351 (Prüfkatalog).'
% (self.employee_name, self.date_from))
if self.kz354 > 2400.0 * lzp_tage / 365.0 * 1.01 + 0.02:
warnungen.append(
'%s (%s): KZ 354 übersteigt den aliquotierten '
'Prüfkatalog-Cap 2.400 € je Jahr (+1 %% Toleranz).'
% (self.employee_name, self.date_from))
if self.hota > lzp_tage:
fehler.append(
'%s (%s): HOTA > LZP-Tage.' % (
self.employee_name, self.date_from))
if self.hopa > self.hota * 3.0 + 0.02:
fehler.append(
'%s (%s): HOPA > 3 € × HOTA.' % (
self.employee_name, self.date_from))
if self.hopa > 300.0 * lzp_tage / 365.0 * 1.01 + 0.02:
warnungen.append(
'%s (%s): HOPA übersteigt den aliquotierten '
'Prüfkatalog-Cap 300 € je Jahr.' % (
self.employee_name, self.date_from))
class L10nAtPayrollL17(models.Model):
_name = 'l10n.at.payroll.l17'
_description = 'L17 — Lohnnachweis/Lohnbescheinigung (Finanz)'
_inherit = ['mail.thread']
_order = 'jahr desc, id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
jahr = fields.Integer(required=True, string='Jahr')
line_ids = fields.One2many(
'l10n.at.payroll.l17.line', 'l17_id', string='A1-Sätze')
datei = fields.Binary(attachment=True, string='L17-Datei', copy=False)
datei_name = fields.Char(copy=False)
protokoll = fields.Text(
string='Prüfprotokoll',
help='Formale Prüfung nach Prüfkatalog L17 (Stand 12/2025) und '
'dokumentierte Reste der KZ-Belegung.')
state = fields.Selection(
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
required=True, tracking=True)
_sql_constraints = [(
'l17_company_jahr_uniq', 'unique(company_id, jahr)',
'Für dieses Jahr existiert bereits eine L17-Deklaration.')]
def _l10n_at_slips(self):
self.ensure_one()
return self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('date_from', '>=', date(self.jahr, 1, 1)),
('date_from', '<=', date(self.jahr, 12, 31)),
('state', 'in', ('validated', 'paid')),
], order='date_from, id')
def _l10n_at_hersteller(self):
partner = self.company_id.partner_id
return {'name': 'odoo-at-payroll', 'kfz': 'A',
'plz': partner.zip, 'ort': partner.city,
'strasse': partner.street}
@api.model
def _vorzeichen(self, betrag):
if not betrag:
return None
return '+' if betrag > 0 else '-'
def _l10n_at_kz_salden(self, slip):
"""(salden, hota) je Lohnzahlungszeitraum aus dem Lauf —
Kennzahlen-Mapping siehe Modul-Doku (GP3-Flags, Muster
`_kz_salden` der L16-Zeile)."""
self.ensure_one()
salden = dict.fromkeys(
('kz350', 'kz351', 'kz352', 'kz354', 'kz357', 'kz347',
'hopa'), 0.0)
hota = 0
for line in slip.line_ids:
rule = line.salary_rule_id
if not line.total:
continue
if rule.l10n_at_bemessung == 'laufend':
salden['kz350'] += line.total
if line.code == 'SB_HOMEOFFICE':
salden['hopa'] += line.total
elif rule.l10n_at_bemessung == 'sonstig':
salden['kz350'] += line.total
if rule.l10n_at_s67_abs in ('abs3', 'abs6'):
salden['kz352'] += line.total
else:
salden['kz351'] += line.total
elif line.code == 'SVDN_SZ':
salden['kz347'] += line.total
elif line.code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV',
'SVDN_AK', 'SVDN_WF'):
salden['kz357'] += line.total
freibe = slip._l10n_at_freibetraege_68()
salden['kz354'] = freibe['ue_std']
for eingabe in slip.input_line_ids:
if eingabe.input_type_id.code == 'ATP_SB_HOMEOFFICE':
hota += int(eingabe.quantity or 0)
return salden, hota
def _l10n_at_a1_satz(self, line, stnr, heute, jetzt):
self.ensure_one()
employee = line.employee_id
version = employee.version_id
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L17_SATZ, 1100,
bezeichnung='A1 %s %s' % (employee.name, line.date_from))
satz.setze('SART', 'A1')
satz.setze('VSTR', '94')
satz.setze('FSART', 'A')
satz.setze('STNRL', stnr)
satz.setze_datum('DTUE', heute, 'JJJJMMTT')
satz.setze_zeit('ZTUE', jetzt)
satz.setze('REFN', line.refn)
satz.setze('FIND', 'J')
satz.setze('BELZ', line.date_from.strftime('%d%m'))
satz.setze('ENLZ', line.date_to.strftime('%d%m'))
satz.setze('JALZ', self.jahr)
satz.setze('PENS', 'N')
satz.setze('AVSN', line.versicherungsnummer)
satz.setze('ANAM', (employee.name or '')[:37])
satz.setze('AADR', (version.private_street or '')[:37]
if version else '')
satz.setze('ALKZ', (version.private_country_id.code
if version and version.private_country_id
else 'AUT'))
satz.setze('APLZ', version.private_zip if version else '')
satz.setze('AORT', (version.private_city or '')[:30]
if version else '')
for betrag, vfeld, bfeld, negativ in (
(line.kz350, 'V350', 'B350', False),
(line.kz351, 'V351', 'B351', False),
(line.kz352, 'V352', 'B352', True),
(line.kz354, 'V354', 'B354', True),
(line.kz357, 'V357', 'B357', False),
(line.kz347, 'V347', 'B347', True)):
if betrag < 0 and not negativ:
raise UserError(
'%s (%s): negativer Betrag in %s — das Vorzeichenfeld '
'zulässigerweise "-" nur nach DM-ORG-Feldbeschreibung.'
% (employee.name, line.date_from, vfeld))
satz.setze(vfeld, self._vorzeichen(betrag))
satz.setze(bfeld, abs(betrag) if betrag else 0, cent=True)
if line.mueb:
satz.setze('MUEB', line.mueb)
if line.hota:
satz.setze('HOTA', line.hota)
if line.hopa:
satz.setze('VHOPA', self._vorzeichen(line.hopa))
satz.setze('HOPA', abs(line.hopa), cent=True)
satz.setze('WOBD', 'EMP-%d' % employee.id)
satz.setze('REFN2', line.refn)
return satz
def action_erzeugen(self):
"""A1 je (Dienstnehmer, Lohnzahlungszeitraum) aus validierten
Läufen; formale Prüfungen nach Prüfkatalog L17; Fehler brechen
ab (UserError, DmfA-Muster), Warnungen ins Protokoll."""
for deklaration in self:
fehler = []
warnungen = []
company = deklaration.company_id
if not company.l10n_at_lohnsteuernummer:
fehler.append(
'Lohnsteuernummer der Company fehlt '
'(l10n_at_lohnsteuernummer).')
deklaration.line_ids.unlink()
for slip in deklaration._l10n_at_slips():
version = slip.version_id or slip.employee_id.version_id
if not (version and version.ssnid):
fehler.append(
'Mitarbeiter %s ohne Versicherungsnummer '
'(hr.version.ssnid).' % slip.employee_id.name)
continue
if not slip.employee_id.birthday:
fehler.append(
'Mitarbeiter %s ohne Geburtsdatum.'
% slip.employee_id.name)
continue
salden, hota = deklaration._l10n_at_kz_salden(slip)
refn = 'L17-%d-%d-%d-%s' % (
company.id, deklaration.jahr, slip.employee_id.id,
slip.date_from.strftime('%Y%m%d'))
line = deklaration.env['l10n.at.payroll.l17.line'].create({
'l17_id': deklaration.id,
'employee_id': slip.employee_id.id,
'date_from': slip.date_from,
'date_to': slip.date_to,
'versicherungsnummer': version.ssnid,
'kz350': salden['kz350'],
'kz351': salden['kz351'],
'kz352': salden['kz352'],
'kz354': salden['kz354'],
'kz357': salden['kz357'],
'kz347': salden['kz347'],
'hota': hota,
'hopa': salden['hopa'],
'mueb': 0,
'refn': refn,
})
line._l10n_at_pruefungen(fehler, warnungen)
if deklaration.line_ids:
for line in deklaration.line_ids:
if line.kz352:
warnungen.append(
'%s: KZ 352 belegt — die SV-Zerlegung auf '
'KZ 736 bleibt dokumentierter Rest (v1 = 0); '
'Prüfkatalog-Grenze 40 %% × KZ 352 prüfen.'
% line.employee_name)
if fehler:
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
'\n'.join(fehler), '\n'.join(warnungen) or '')
raise UserError('L17-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler))
if deklaration.line_ids:
heute = date.today()
jetzt = datetime.now()
partner = company.partner_id
stnr = company.l10n_at_lohnsteuernummer
informationssatz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L16_INFORMATIONSSATZ, 1100,
bezeichnung='L17-Informationssatz')
informationssatz.setze('SART', 'I1')
informationssatz.setze('VSTR', '94')
informationssatz.setze('FSART', 'I')
informationssatz.setze('CLEAR', 'RADAUS')
informationssatz.setze('STNRL', stnr)
informationssatz.setze('STNRA', stnr)
informationssatz.setze('ARTD', 'LZ')
informationssatz.setze_datum('DTUE', heute, 'JJJJMMTT')
informationssatz.setze_zeit('ZTUE', jetzt)
informationssatz.setze('STVE', '03')
informationssatz.setze('ANAM', (partner.name or '')[:37])
informationssatz.setze('AADR', (partner.street or '')[:37])
informationssatz.setze('ALKZ', 'AUT')
informationssatz.setze('APLZ', partner.zip)
informationssatz.setze('AORT', (partner.city or '')[:30])
informationssatz.setze('JAHR', deklaration.jahr)
informationssatz.setze('GESA', len(deklaration.line_ids))
informationssatz.setze('ANZA', len(deklaration.line_ids))
datensaetze = [informationssatz] + [
deklaration._l10n_at_a1_satz(line, stnr, heute, jetzt)
for line in deklaration.line_ids]
bestand = L10nAtEldaBestand(
'LF', '94', '28', hersteller=deklaration
._l10n_at_hersteller(), datensaetze=datensaetze)
datei = L10nAtEldaDatei([bestand])
deklaration.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'l17_%d_%s.txt' % (
deklaration.jahr, stnr),
})
deklaration.protokoll = 'WARNUNGEN:\n%s' % (
'\n'.join(warnungen) or '')
deklaration.state = 'done'
return True
@@ -0,0 +1,573 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Finanz-Mitteilungen § 109a/§ 109b EStG (AP17) — DM-ORG 42.7.0,
Kapitel E.16 (Satzart ``W1``, Code W, Länge 1100, Version 28) und
E.26 (Satzart ``B1``, Code B, Länge 1100, Version 28); PDF-verifizierte
ODL-Extraktion 2026-09-16. Beide Mitteilungssätze laufen im BEST ``LF`` /
VSTR ``94`` mit dem Informationssatz I1 (ARTD LZ, D.4).
Rechtsanker (RIS-verifiziert 2026-09-16, `.additional_docs/ris_normtexte`
-Regime: NormDokument.wxe-Downloads):
- **§ 109a EStG** (NOR40263382, Fassung 20.07.2024): Mitteilungen über
Leistungen von (Sub-)Unternehmern/PVG Name/Anschrift/SVN bzw. StNr,
Art der Leistung (D.34: 8 Tätigkeitsgruppen), Kalenderjahr, Entgelt +
Umsatzsteuer. Entgelt = ohne USt einschließlich Kostenersätze und
Sachbezüge; bei freien Dienstnehmern inkl. DN-SV-Anteil und
BMV-Kassen-Zahlungen (B270/B271).
- **§ 109b EStG** (NOR40263383, Fassung 20.07.2024): Zahlungen INS
AUSLAND für Leistungen § 22 (AL31), Vermittlung (AL32), kaufm./techn.
Beratung im Inland (AL33). Angaben je Leistungserbringer (incl.
Länderkennung(en) der Zielländer, UID) sowie je Personengemeinschaft/
Körperschaft die im Inland maßgeblich auftretende natürliche Person
(bis 3 NP-Blöcke). Mitteilung unterbleibt, wenn a) die Kalenderjahres-
Zahlungen desselben Leistungserbringers 100.000 nicht übersteigen,
b) ein Steuerabzug § 99 zu erfolgen hat, c) die ausländische KSt um
nicht mehr als 10 %-Punkte unter der österreichischen KSt liegt
(Abs 4; a) automatisiert, b)/c) als Fallauswahl).
- **Fristen** (Fristenkalender): § 109a Ende Februar (Fristart
`paragraph_109a`); § 109b Abs 6 elektronisch bis Ende Februar des
Folgejahres (amtlicher Vordruck bis Ende Jänner).
Schwellen als `hr.rule.parameter` `at_m109_schwellen` (versioniert):
m109a_leistung 450 , m109a_kumulativ 900 (je Leistungserbringer,
Kalenderjahr Fristenkalender/§-109a-VO), m109b_jahr 100.000 .
Storno: KORR S (W1/B1) E.16.1/E.26.1 zulassen beide Varianten
(Beträge auf 0 oder KORR S)."""
from datetime import date, datetime
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
from .elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
)
from .lohnzettel_l16 import L16_INFORMATIONSSATZ
# ------------------------------------------------------------------
# W1 — Mitteilungen gemäß § 109a EStG (E.16; Länge 1100) — Datenteil
# ------------------------------------------------------------------
MITTEILUNG_W1_SATZ = [
(2, 'FSART', 21, 1, 'a'), # W
(3, 'CLADR', 22, 6, 'a'),
(4, 'CLDVR', 28, 7, 'a'),
(6, 'STNRL', 35, 9, 'n'),
(7, 'DVRNL', 44, 7, 'n'),
(8, 'ARTU', 51, 1, 'a'),
(9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1
(10, 'ZTUE', 60, 6, 'n'),
(11, 'REFN', 66, 30, 'a'), # je AG+Jahr+Auftragsverhältnis eindeutig
(12, 'INTE', 96, 2, 'a'),
(13, 'FEHL', 98, 20, 'a'),
(14, 'STAT', 118, 1, 'a'),
(15, 'FIND', 119, 1, 'a'),
(16, 'JAHR', 120, 4, 'n'),
(17, 'AVSN', 124, 10, 'n'), # Auftragnehmer VN LLLPTTMMJJ
(19, 'ASTN', 134, 9, 'n'), # Auftragnehmer Steuernummer
(20, 'ANAM', 143, 37, 'a'),
(21, 'ATIT', 180, 20, 'a'),
(22, 'AADR', 200, 37, 'a'),
(23, 'ALKZ', 237, 3, 'a'),
(24, 'APLZ', 240, 10, 'a'),
(25, 'AORT', 250, 30, 'a'),
(26, 'ARTE', 280, 2, 'n'), # Art der erbrachten Leistung (D.34)
(27, 'V341', 282, 1, 'a'),
(28, 'B341', 283, 10, 'n'), # Entgelt (KZ 341)
(29, 'V344', 293, 1, 'a'),
(30, 'B344', 294, 10, 'n'), # Umsatzsteuer (KZ 344)
(31, 'FANRA', 304, 2, 'n'), # Finanzamtsnummer Auftraggeber
(32, 'STNRA', 306, 7, 'n'), # Steuernummer Auftraggeber
(33, 'DVRNA', 313, 7, 'n'), # DVR-Nummer Auftraggeber
(34, 'TELN', 320, 20, 'a'),
(35, 'FAXN', 340, 20, 'a'),
(36, 'KORR', 360, 1, 'a'), # K/S
(37, 'WOBD', 361, 12, 'a'),
(38, 'ZOBD', 373, 15, 'a'),
(39, 'V270', 388, 1, 'a'),
(40, 'B270', 389, 10, 'n'), # DN-Anteil SV (KZ 270)
(41, 'V271', 399, 1, 'a'),
(42, 'B271', 400, 10, 'n'), # eingezahlter BMV-Kasse-Beitrag
(43, 'RE43', 410, 591, 'a'),
(44, 'REFN2', 1001, 30, 'a'),
(45, 'RE45', 1031, 70, 'a'),
]
# ------------------------------------------------------------------
# B1 — Mitteilungen gemäß § 109b EStG (E.26; Länge 1100) — Datenteil
# ------------------------------------------------------------------
MITTEILUNG_B1_SATZ = [
(2, 'FSART', 21, 1, 'a'), # B
(3, 'CLADR', 22, 6, 'a'),
(4, 'CLDVR', 28, 7, 'a'),
(6, 'STNRL', 35, 9, 'n'),
(7, 'DVRNL', 44, 7, 'n'),
(8, 'ARTU', 51, 1, 'a'),
(9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1
(10, 'ZTUE', 60, 6, 'n'),
(11, 'REFN', 66, 30, 'a'), # je AG+Jahr+Auftragsverhältnis eindeutig
(12, 'INTE', 96, 2, 'a'),
(13, 'FEHL', 98, 20, 'a'),
(14, 'STAT', 118, 1, 'a'),
(15, 'FIND', 119, 1, 'a'),
(16, 'JAHR', 120, 4, 'n'),
(17, 'LVSN', 124, 10, 'n'), # Leistungserbringer VN LLLPTTMMJJ
(19, 'LSTN', 134, 9, 'n'), # Leistungserbringer Steuernummer
(20, 'LNAM', 143, 37, 'a'),
(21, 'RE21', 180, 20, 'a'),
(22, 'LADR', 200, 37, 'a'),
(23, 'LLKZ', 237, 3, 'a'),
(24, 'LPLZ', 240, 10, 'a'),
(25, 'LORT', 250, 30, 'a'),
(26, 'RE26', 280, 2, 'a'),
(27, 'V499', 282, 1, 'a'),
(28, 'B499', 283, 10, 'n'), # Zahlungen inkl. USt (KZ 499)
(29, 'V344', 293, 1, 'a'),
(30, 'B344', 294, 10, 'n'), # darin enthaltene USt (KZ 344)
(32, 'STNRA', 304, 9, 'n'), # Steuernummer Auftraggeber
(33, 'RE33', 313, 7, 'a'),
(34, 'TELN', 320, 20, 'a'),
(35, 'FAXN', 340, 20, 'a'),
(36, 'KORR', 360, 1, 'a'), # K/S
(37, 'AL31', 361, 1, 'a'), # § 109b Abs 2 Z 1 (§-22-Tätigkeiten)
(38, 'AL32', 362, 1, 'a'), # Z 2 Vermittlungsleistungen
(39, 'AL33', 363, 1, 'a'), # Z 3 kaufm./techn. Beratung
(40, 'LKZ1', 364, 3, 'a'),
(41, 'LKZ2', 367, 3, 'a'),
(42, 'LKZ3', 370, 3, 'a'),
(43, 'LKZ4', 373, 3, 'a'),
(44, 'LKZ5', 376, 3, 'a'),
(45, 'LKZ6', 379, 3, 'a'),
(46, 'LKZ7', 382, 3, 'a'),
(47, 'UIDL', 385, 15, 'a'), # UID Leistungserbringer
(48, 'UIDA', 400, 15, 'a'), # UID Auftraggeber
(49, 'VSNRA', 415, 10, 'n'), # VN Auftraggeber
(50, 'NPVS1', 425, 10, 'n'), # NP-Block 1 (maßgebliche Person)
(51, 'NPFA1', 435, 2, 'n'),
(52, 'NPST1', 437, 7, 'n'),
(53, 'NPNA1', 444, 30, 'a'),
(54, 'NPAD1', 474, 30, 'a'),
(55, 'NPLK1', 504, 3, 'a'),
(56, 'NPPL1', 507, 10, 'a'),
(57, 'NPOR1', 517, 30, 'a'),
(58, 'NPUI1', 547, 15, 'a'),
(59, 'NPVS2', 562, 10, 'n'),
(60, 'NPFA2', 572, 2, 'n'),
(61, 'NPST2', 574, 7, 'n'),
(62, 'NPNA2', 581, 30, 'a'),
(63, 'NPAD2', 611, 30, 'a'),
(64, 'NPLK2', 641, 3, 'a'),
(65, 'NPPL2', 644, 10, 'a'),
(66, 'NPOR2', 654, 30, 'a'),
(67, 'NPUI2', 684, 15, 'a'),
(68, 'NPVS3', 699, 10, 'n'),
(69, 'NPFA3', 709, 2, 'n'),
(70, 'NPST3', 711, 7, 'n'),
(71, 'NPNA3', 718, 30, 'a'),
(72, 'NPAD3', 748, 30, 'a'),
(73, 'NPLK3', 778, 3, 'a'),
(74, 'NPPL3', 781, 10, 'a'),
(75, 'NPOR3', 791, 30, 'a'),
(76, 'NPUI3', 821, 15, 'a'),
(77, 'WOBD', 836, 12, 'a'),
(78, 'ZOBD', 848, 15, 'a'),
(79, 'RE79', 863, 123, 'a'),
(80, 'STAT', 986, 5, 'a'),
(81, 'NMEL', 991, 7, 'n'),
(82, 'RE82', 998, 3, 'a'),
(83, 'REFN2', 1001, 30, 'a'),
(84, 'RE84', 1031, 70, 'a'),
]
# W1/B1 gemeinsame Vorzeichen-/Betragsfelder je Zeile.
_BETRAEGE_W1 = (('entgelt', 'V341', 'B341', True),
('umsatzsteuer', 'V344', 'B344', True),
('dn_sv_anteil', 'V270', 'B270', True),
('bmv_beitrag', 'V271', 'B271', True))
_BETRAEGE_B1 = (('entgelt', 'V499', 'B499', True),
('umsatzsteuer', 'V344', 'B344', True))
class L10nAtPayrollMitteilung109Np(models.Model):
_name = 'l10n.at.payroll.mitteilung109.np'
_description = '§ 109b — maßgeblich auftretende natürliche Person'
_order = 'sequence'
sequence = fields.Integer(default=10)
line_id = fields.Many2one(
'l10n.at.payroll.mitteilung109.line', required=True,
ondelete='cascade', index=True)
versicherungsnummer = fields.Char(string='VN (LLLPTTMMJJ)')
finanzamtsnummer = fields.Char(size=2)
steuernummer = fields.Char(size=7)
name = fields.Char(string='Name (Familienname Vorname)')
adresse = fields.Char()
landeskennung = fields.Char(size=3)
plz = fields.Char(size=10)
ort = fields.Char()
uid = fields.Char(string='UID-Nummer')
class L10nAtPayrollMitteilung109Line(models.Model):
_name = 'l10n.at.payroll.mitteilung109.line'
_description = 'Finanz-Mitteilung je Auftraggeber/Auftragnehmer'
_order = 'name_partner'
deklaration_id = fields.Many2one(
'l10n.at.payroll.mitteilung109', required=True,
ondelete='cascade', index=True)
name_partner = fields.Char(
string='Auftragnehmer/Leistungserbringer', required=True)
versicherungsnummer = fields.Char(
string='VN (LLLPTTMMJJ)',
help='D.6-Format; statt VN ist das Geburtsdatenformat ohne '
'VN-Prüfziffer zulässig (Storno-Regel der Kapitel E.16/E.26).')
steuernummer = fields.Char(string='Steuernummer (7, Prüfziffer an 7)')
uid = fields.Char(string='UID-Nummer')
name = fields.Char(string='Familienname und Vorname')
adresse = fields.Char()
landeskennung = fields.Char(size=3, default='AUT')
plz = fields.Char(size=10)
ort = fields.Char(size=30)
entgelt = fields.Monetary(
currency_field='currency_id',
help='W1: Entgelt ohne USt inkl. Kostenersätze und Sachbezüge '
'(bei freien Dienstnehmern inkl. DN-SV-Anteil und BMV-Kasse). '
'B1: Zahlungen einschließlich Umsatzsteuer (KZ 499).')
umsatzsteuer = fields.Monetary(
currency_field='currency_id',
help='W1: in Rechnung gestellte USt (KZ 344); B1: darin '
'enthaltene USt (KZ 344).')
dn_sv_anteil = fields.Monetary(
currency_field='currency_id',
string='DN-Anteil SV (KZ 270, W1)')
bmv_beitrag = fields.Monetary(
currency_field='currency_id',
string='BMV-Kasse-Beitrag (KZ 271, W1)')
art_leistung = fields.Selection(
[(code, label) for code, label in (
('01', 'Aufsichtsrat/Verwaltungsrat'),
('02', 'Bausparkassen-/Versicherungsvertreter'),
('03', 'Stiftungsvorstand'),
('04', 'Vortragende/Lehrende/Unterrichtende'),
('05', 'Kolporteure/Zeitungszusteller'),
('06', 'Privatgeschäftsvermittler'),
('07', 'Funktionäre öff.-rechtl. Körperschaften'),
('08', 'sonstige Leistungen (freier DV, § 4 Abs 4 ASVG)'))],
string='Art der erbrachten Leistung (D.34)')
al31 = fields.Boolean(
string='§ 109b Abs 2 Z 1 (§ 22-Tätigkeit im Inland)')
al32 = fields.Boolean(string='§ 109b Abs 2 Z 2 (Vermittlung)')
al33 = fields.Boolean(string='§ 109b Abs 2 Z 3 (Beratung im Inland)')
ziellaender = fields.Char(
string='Länderkennungen der Zielländer',
help='Bis zu 7 internationale Länderkennungen à 3 Stellen, '
'komma-separiert (z. B. DE,CH).')
ausnahme_grund = fields.Selection(
[('keine', 'Mitteilungspflicht'),
('steuerabzug', 'Steuerabzug gem. § 99 EStG (Abs 4)'),
('kst_gleich', 'Ausländische KSt nicht > 10 %-Punkte unter '
'österreichischer KSt (Abs 4)')],
string='B1-Ausnahme (§ 109b Abs 4)',
default='keine',
help='Die 100.000-€-Schwelle prüft der Lauf automatisch; '
'Steuerabzug und KSt-Vergleich sind Sachentscheidungen '
'als Auswahlfelder.')
refn = fields.Char()
gemeldet = fields.Boolean(
string='Meldepflichtig (Schwellenprüfung)',
default=True, readonly=True,
help='Wird vom Lauf gesetzt: Zeilen unter den Schwellen bzw. mit '
'B1-Ausnahme entfallen aus der Datei.')
korr = fields.Selection(
[('keine', 'Neue Mitteilung'), ('K', 'Berichtigung'),
('S', 'Storno')], default='keine', string='Korrekturindikation')
np_ids = fields.One2many(
'l10n.at.payroll.mitteilung109.np', 'line_id',
string='Maßgebliche natürliche Personen (max. 3)')
currency_id = fields.Many2one(
'res.currency',
related='deklaration_id.company_id.currency_id')
class L10nAtPayrollMitteilung109(models.Model):
_name = 'l10n.at.payroll.mitteilung109'
_description = 'Finanz-Mitteilungen § 109a/§ 109b EStG'
_inherit = ['mail.thread']
_order = 'jahr desc, art, id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
jahr = fields.Integer(required=True, string='Kalenderjahr')
art = fields.Selection(
[('w1', '§ 109a — Werkverträge (W1)'),
('b1', '§ 109b — Auslandszahlungen (B1)')],
required=True, string='Mitteilungsart')
line_ids = fields.One2many(
'l10n.at.payroll.mitteilung109.line', 'deklaration_id',
string='Mitteilungssätze')
datei = fields.Binary(attachment=True, string='LF-Datei', copy=False)
datei_name = fields.Char(copy=False)
protokoll = fields.Text(string='Prüfprotokoll')
state = fields.Selection(
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
required=True, tracking=True)
_sql_constraints = [(
'm109_company_jahr_art_uniq', 'unique(company_id, jahr, art)',
'Für dieses Jahr existiert bereits eine Deklaration dieser Art.')]
def _l10n_at_schwellen(self):
self.ensure_one()
return self.env['hr.rule.parameter']._get_parameter_from_code(
'at_m109_schwellen', date(self.jahr, 1, 1),
raise_if_not_found=False) or {}
def _l10n_at_hersteller(self):
partner = self.company_id.partner_id
return {'name': 'odoo-at-payroll', 'kfz': 'A',
'plz': partner.zip, 'ort': partner.city,
'strasse': partner.street}
def _l10n_at_satz(self, line, stnr, heute, jetzt):
"""W1/B1-Mitteilungssatz je Zeile; IDTEIL (SART W1/B1, VSTR 94)."""
self.ensure_one()
if self.art == 'w1':
struktur, sart, fsart = MITTEILUNG_W1_SATZ, 'W1', 'W'
else:
struktur, sart, fsart = MITTEILUNG_B1_SATZ, 'B1', 'B'
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + struktur, 1100,
bezeichnung='%s %s' % (sart, line.name_partner))
satz.setze('SART', sart)
satz.setze('VSTR', '94')
satz.setze('FSART', fsart)
satz.setze('STNRL', stnr)
satz.setze_datum('DTUE', heute, 'JJJJMMTT')
satz.setze_zeit('ZTUE', jetzt)
satz.setze('REFN', line.refn)
satz.setze('FIND', 'J')
satz.setze('JAHR', self.jahr)
if self.art == 'w1':
satz.setze('AVSN', line.versicherungsnummer)
satz.setze('ASTN', line.steuernummer)
satz.setze('ANAM', (line.name or '')[:37])
satz.setze('AADR', (line.adresse or '')[:37])
satz.setze('ALKZ', (line.landeskennung or 'AUT')[:3])
satz.setze('APLZ', (line.plz or '')[:10])
satz.setze('AORT', (line.ort or '')[:30])
satz.setze('ARTE', line.art_leistung)
for feld, vfeld, bfeld, _neg in _BETRAEGE_W1:
betrag = getattr(line, feld)
satz.setze(vfeld, self._vorzeichen(betrag))
satz.setze(bfeld, abs(betrag) if betrag else 0, cent=True)
satz.setze('FANRA', (stnr or '')[:2])
satz.setze('STNRA', (stnr or '')[2:9])
satz.setze('WOBD', 'LE-%d' % line.id)
else:
satz.setze('LVSN', line.versicherungsnummer)
satz.setze('LSTN', line.steuernummer)
satz.setze('LNAM', (line.name or '')[:37])
satz.setze('LADR', (line.adresse or '')[:37])
satz.setze('LLKZ', (line.landeskennung or 'AUT')[:3])
satz.setze('LPLZ', (line.plz or '')[:10])
satz.setze('LORT', (line.ort or '')[:30])
for feld, vfeld, bfeld, _neg in _BETRAEGE_W1:
if feld == 'umsatzsteuer':
satz.setze(vfeld, self._vorzeichen(line.umsatzsteuer))
satz.setze(bfeld, abs(line.umsatzsteuer)
if line.umsatzsteuer else 0, cent=True)
elif feld == 'entgelt':
satz.setze('V499', self._vorzeichen(line.entgelt))
satz.setze('B499', abs(line.entgelt) if line.entgelt
else 0, cent=True)
satz.setze('AL31', 'J' if line.al31 else 'N')
satz.setze('AL32', 'J' if line.al32 else 'N')
satz.setze('AL33', 'J' if line.al33 else 'N')
laender = [k.strip().upper() for k in (
line.ziellaender or '').split(',') if k.strip()][:7]
if laender:
for idx, laender_code in enumerate(laender, start=1):
satz.setze('LKZ%d' % idx, laender_code)
satz.setze('UIDL', (line.uid or '')[:15])
satz.setze('UIDA', (self.company_id.vat or '')[:15])
satz.setze('STNRA', stnr)
satz.setze('WOBD', 'LE-%d' % line.id)
for nummer, np_zeile in enumerate(
line.np_ids.sorted('sequence')[:3], start=1):
satz.setze('NPVS%d' % nummer, np_zeile.versicherungsnummer)
satz.setze('NPFA%d' % nummer, np_zeile.finanzamtsnummer)
satz.setze('NPST%d' % nummer, np_zeile.steuernummer)
satz.setze('NPNA%d' % nummer, (np_zeile.name or '')[:30])
satz.setze('NPAD%d' % nummer, (np_zeile.adresse or '')[:30])
satz.setze('NPLK%d' % nummer,
(np_zeile.landeskennung or '')[:3])
satz.setze('NPPL%d' % nummer, (np_zeile.plz or '')[:10])
satz.setze('NPOR%d' % nummer, (np_zeile.ort or '')[:30])
satz.setze('NPUI%d' % nummer, (np_zeile.uid or '')[:15])
satz.setze('REFN2', line.refn)
return satz
@api.model
def _vorzeichen(self, betrag):
if not betrag:
return None
return '+' if betrag > 0 else '-'
@api.model
def _vorzeichen(self, betrag):
if not betrag:
return None
return '+' if betrag > 0 else '-'
def action_erzeugen(self):
"""W1/B1-Mitteilungssätze je Zeile bauen; Schwellen/Ausnahmen
prüfen (§ 109a-VO 450/900 je Leistungserbringer; § 109b Abs 4
100.000 je Leistungserbringer und Kalenderjahr); unter-
schwellige Zeilen bleiben mit ``gemeldet = False`` (Protokoll-
Hinweis) und entfallen aus der Datei. LF-Bestand: I1 (ARTD 'LZ')
+ die meldepflichtigen Sätze."""
for deklaration in self:
fehler = []
warnungen = []
company = deklaration.company_id
stnr = company.l10n_at_lohnsteuernummer
if not stnr:
fehler.append('Lohnsteuernummer der Company fehlt.')
if deklaration.art == 'w1' and len(stnr or '') < 9:
fehler.append(
'FANRA/STNRA erfordern eine 9-stellige '
'Lohnsteuernummer (2 FA + 7 StNr).')
schwellen = deklaration._l10n_at_schwellen()
if not schwellen:
fehler.append(
'Parameter at_m109_schwellen fehlt (Werte nur aus '
'Quelle — RIS/VO-Verifikation vor Belegung).')
if fehler:
deklaration.protokoll = 'FEHLER:\n%s' % (
'\n'.join(fehler))
raise UserError('Mitteilungs-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler))
# Schwellenprüfung je Leistungserbringer (Kalenderjahr).
if deklaration.art == 'w1':
je_leistung = float(schwellen.get('m109a_leistung', 0.0))
kumulativ_cap = float(
schwellen.get('m109a_kumulativ', 0.0))
else:
je_leistung = None
kumulativ_cap = float(
schwellen.get('m109b_jahr', 0.0))
nach_le = {}
for line in deklaration.line_ids:
schluessel = (line.name_partner, line.steuernummer or '',
line.versicherungsnummer or '')
nach_le.setdefault(schluessel, []).append(line)
for schluessel, zeilen in nach_le.items():
summe = sum(z.entgelt for z in zeilen)
for line in zeilen:
gemeldet = True
if deklaration.art == 'w1':
unter_schwellen = (
(line.entgelt or 0) <= je_leistung
and summe <= kumulativ_cap)
if unter_schwellen:
gemeldet = False
warnungen.append(
'%s: Entgelt %.2f unter den §-109a-'
'Schwellen (je Leistung ≤ %.2f, kumulativ '
'je LE ≤ %.2f) — keine Meldung.' % (
line.name_partner, line.entgelt or 0,
je_leistung, kumulativ_cap))
else:
if line.ausnahme_grund != 'keine':
gemeldet = False
warnungen.append(
'%s: Ausnahme nach § 109b Abs 4 '
'(%s) — keine Mitteilung.' % (
line.name_partner,
line.ausnahme_grund))
elif summe <= kumulativ_cap:
gemeldet = False
warnungen.append(
'%s: Kalenderjahres-Zahlungen %.2f '
'übersteigen 100.000 € nicht '
'(§ 109b Abs 4 lit a) — keine '
'Mitteilung.' % (
line.name_partner, summe))
elif not (line.al31 or line.al32 or line.al33):
fehler.append(
'%s: B1 ohne Art der Leistung (AL31/32/33).'
% line.name_partner)
elif not (line.ziellaender or '').strip():
fehler.append(
'%s: B1 ohne Zielländer-Länderkennung.'
% line.name_partner)
line.gemeldet = gemeldet
if deklaration.art == 'b1':
for line in deklaration.line_ids:
np_anzahl = len(line.np_ids)
ist_koerperschaft = bool(
line.steuernummer and not line.versicherungsnummer)
if ist_koerperschaft and not np_anzahl:
warnungen.append(
'%s: Personenvereinigung/Körperschaft als '
'Leistungserbringer ohne maßgeblich '
'auftretende natürliche Person '
'(§ 109b Abs 3 Z 2) prüfen.'
% line.name_partner)
if np_anzahl > 3:
fehler.append(
'%s: mehr als 3 maßgebliche natürliche '
'Personen (DM-ORG: 3 Blöcke).' % (
line.name_partner))
if fehler:
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
'\n'.join(fehler), '\n'.join(warnungen) or '')
raise UserError('Mitteilungs-Lauf mit Fehlern '
'abgebrochen:\n' + '\n'.join(fehler))
gemeldete = deklaration.line_ids.filtered('gemeldet')
if gemeldete:
heute = date.today()
jetzt = datetime.now()
partner = company.partner_id
informationssatz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L16_INFORMATIONSSATZ, 1100,
bezeichnung='Mitteilungs-Informationssatz')
informationssatz.setze('SART', 'I1')
informationssatz.setze('VSTR', '94')
informationssatz.setze('FSART', 'I')
informationssatz.setze('CLEAR', 'RADAUS')
informationssatz.setze('STNRL', stnr)
informationssatz.setze('STNRA', stnr)
informationssatz.setze('ARTD', 'LZ')
informationssatz.setze_datum('DTUE', heute, 'JJJJMMTT')
informationssatz.setze_zeit('ZTUE', jetzt)
informationssatz.setze('STVE', '03')
informationssatz.setze('ANAM', (partner.name or '')[:37])
informationssatz.setze('AADR', (partner.street or '')[:37])
informationssatz.setze('ALKZ', 'AUT')
informationssatz.setze('APLZ', partner.zip)
informationssatz.setze('AORT', (partner.city or '')[:30])
informationssatz.setze('JAHR', deklaration.jahr)
informationssatz.setze('GESA', len(gemeldete))
informationssatz.setze('ANZA', len(gemeldete))
datensaetze = [informationssatz] + [
deklaration._l10n_at_satz(line, stnr, heute, jetzt)
for line in gemeldete]
bestand = L10nAtEldaBestand(
'LF', '94', '28', hersteller=deklaration
._l10n_at_hersteller(), datensaetze=datensaetze)
datei = L10nAtEldaDatei([bestand])
deklaration.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'mitteilung109_%s_%d_%s.txt' % (
deklaration.art, deklaration.jahr, stnr),
})
deklaration.protokoll = 'WARNUNGEN:\n%s' % (
'\n'.join(warnungen) or '')
deklaration.state = 'done'
return True
@@ -0,0 +1,269 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Schwerarbeitsmeldung (AP18) — DM-ORG 42.7.0, Kapitel E.22
(BEST ``SM``, SART 65 Schwerarbeitsmeldung / 66 Storno, Länge 800,
Version 02; PDF-verifizierte ODL-Extraktion 2026-09-16).
Rechtsanker (RIS-verifiziert 2026-09-16):
- **ASchV** (RIS GesNr 20004642, BGBl II 1998/419 idF NOR40272279):
§ 1 Abs 1 Z 126 (Z 1 Schicht-/Wechseldienst in Nachtarbeit 226 Uhr
6 h; Z 2 Hitze/Kälte NSchG Art VII Z 2/3; Z 3 chemisch/physikalisch;
Z 4 schwere körperliche Arbeit 2.000 kcal (M) / 1.400 kcal (F);
Z 5 Pflege; Z 6 Pflegegeld), § 4 (Schwerarbeitsmonat Versicherungs-
monat § 231 Z 1 lit a ASVG; Z 5: 12 Tage Schichtdienst), § 5 (Meldung
M 40 / F 35, bis Ende Februar).
- **Erstellvorschriften (DM-ORG E.22.2):** meldepflichtig Z 1, 2, 4 und 5;
Z 1 nur bei 6 Arbeitstagen/Monat Schicht-/Wechseldienst in Nachtarbeit
(bei unterschiedlicher Nachtschichten-Anzahl analog **NSchG Art XI
Abs 6**); Z 2/4/5 nur bei **15 Arbeitstagen/Monat**; Z 3 nicht melden
(MdE 10 % erst nachträglich); Z 6 nur bei bekannter Pflegegeldeinstu-
fung (ab Stufe 3); geringfügig Beschäftigte nicht melden.
- **lb-swa-05** (Stand 2026-07): jährlich JännerEnde Februar; GfB aus-
geschlossen; fallweise/freie DN zu melden; Leiharbeiter vom Überlasser.
Die Ziffern/Zeiträume sind mandantenseitige Fakten (Arbeitsplatz-
Klassifikation) das Modell erfasst sie manuell und erzeugt den Fixsatz;
keine automatisierte Ableitung aus Arbeitszeiten."""
from datetime import date, datetime
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
from .elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
)
from .lohnzettel_l16 import l10n_at_namen
# ------------------------------------------------------------------
# SART 65/66 (E.22; Länge 800) — Datenteil inkl. 26 Tätigkeitsblöcken
# ------------------------------------------------------------------
SCHWERARBEIT_SATZ = [
(2, 'BKNR', 21, 10, 'a'), # Beitragskontonummer
(3, 'DGNA', 31, 70, 'a'),
(4, 'WOBD', 101, 12, 'a'),
(5, 'ZOBD', 113, 15, 'a'),
(6, 'DTEL', 128, 50, 'a'), # DG-Telefon
(7, 'MAIL', 178, 60, 'a'), # DG-Mail
(8, 'DKFZ', 238, 3, 'a'), # DG Land/Kfz
(9, 'DPLZ', 241, 9, 'a'),
(10, 'DORT', 250, 40, 'a'),
(11, 'DSTR', 290, 50, 'a'),
(12, 'VSNR', 340, 10, 'n'), # LLLPTTMMJJ
(13, 'GEBD', 350, 8, 'n'), # TTMMJJJJ
(14, 'FANA', 358, 70, 'a'),
(15, 'VONA', 428, 70, 'a'),
(16, 'JAHR', 498, 4, 'n'), # Tätigkeitsjahr
# Tätigkeitsblöcke: 26 × 10 = 260 (pos 502761)
(17, 'TART', 502, 2, 'a'), # § 1 Abs 1 Z x ASchV
(18, 'TVON', 504, 4, 'n'), # TTMM
(19, 'TBIS', 508, 4, 'n'), # TTMM
(20, 'REFN', 762, 30, 'a'),
(21, 'RE21', 792, 9, 'a'),
]
def _taetigkeitsblock_struktur():
"""26 Tätigkeitsblöcke à 10 Stellen (TART 2 + TVON 4 + TBIS 4)."""
felder = []
for block_nr in range(1, 27):
basis = 502 + (block_nr - 1) * 10
felder.extend([
(17 + (block_nr - 1) * 3, 'TART_%d' % block_nr,
basis, 2, 'a'),
(18 + (block_nr - 1) * 3, 'TVON_%d' % block_nr,
basis + 2, 4, 'n'),
(19 + (block_nr - 1) * 3, 'TBIS_%d' % block_nr,
basis + 6, 4, 'n'),
])
return felder
SCHWERARBEIT_SATZ += _taetigkeitsblock_struktur()
class L10nAtPayrollSchwerarbeitLine(models.Model):
_name = 'l10n.at.payroll.schwerarbeitsmeldung.line'
_description = 'Schwerarbeitsmeldung — Satzdaten je AN'
_order = 'employee_name'
meldung_id = fields.Many2one(
'l10n.at.payroll.schwerarbeitsmeldung', required=True,
ondelete='cascade', index=True)
employee_id = fields.Many2one('hr.employee', required=True)
employee_name = fields.Char(related='employee_id.name',
readonly=True, store=True)
versicherungsnummer = fields.Char()
taetigkeit_ids = fields.One2many(
'l10n.at.payroll.schwerarbeitsmeldung.taetigkeit', 'line_id',
string='Tätigkeitsblöcke')
refn = fields.Char()
class L10nAtPayrollSchwerarbeitTaetigkeit(models.Model):
_name = 'l10n.at.payroll.schwerarbeitsmeldung.taetigkeit'
_description = 'Schwerarbeitsmeldung — Tätigkeitsblock (§ 1 ASchV)'
_order = 'sequence'
sequence = fields.Integer(default=10)
line_id = fields.Many2one(
'l10n.at.payroll.schwerarbeitsmeldung.line', required=True,
ondelete='cascade', index=True)
ziffer = fields.Integer(
string='ASchV § 1 Abs 1 Ziffer', required=True,
help='TART-Codes 126 der Schwerarbeitsverordnung. Nicht zu '
'melden: Z 3 (MdE-Feststellung nachträglich) und Z 6 ohne '
'bekannte Pflegegeldeinstufung ab Stufe 3.')
von = fields.Date(string='Von (TTMM)')
bis = fields.Date(string='Bis (TTMM)')
@api.constrains('ziffer')
def _check_ziffer(self):
for taetigkeit in self:
if not 1 <= taetigkeit.ziffer <= 26:
raise UserError(
'ASchV-Ziffer %s außerhalb 126.' % taetigkeit.ziffer)
class L10nAtPayrollSchwerarbeitsmeldung(models.Model):
_name = 'l10n.at.payroll.schwerarbeitsmeldung'
_description = 'Schwerarbeitsmeldung (§ 74 Abs 3 ASVG / § 5 ASchV)'
_inherit = ['mail.thread']
_order = 'jahr desc, id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
jahr = fields.Integer(required=True, string='Tätigkeitsjahr')
line_ids = fields.One2many(
'l10n.at.payroll.schwerarbeitsmeldung.line', 'meldung_id',
string='Meldesätze')
datei = fields.Binary(attachment=True, string='SM-Datei', copy=False)
datei_name = fields.Char(copy=False)
protokoll = fields.Text(string='Prüfprotokoll')
state = fields.Selection(
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
required=True, tracking=True)
_sql_constraints = [(
'sm_company_jahr_uniq', 'unique(company_id, jahr)',
'Für dieses Jahr existiert bereits eine Schwerarbeitsmeldung.')]
def _l10n_at_hersteller(self):
partner = self.company_id.partner_id
return {'name': 'odoo-at-payroll', 'kfz': 'A',
'plz': partner.zip, 'ort': partner.city,
'strasse': partner.street}
def action_erzeugen(self):
"""SART 65 je AN (Tätigkeitsblöcke 26 × 10) — Erstellvorschriften
(E.22.2): Z 1 6 Nachtarbeitstage/Monat (analog NSchG Art XI
Abs 6), Z 2/4/5 15 Arbeitstage/Monat, GfB ausgeschlossen,
Altersfilter M 40 / F 35 als Warnung."""
for meldung in self:
fehler = []
warnungen = []
company = meldung.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
fehler.append('Beitragskontonummer (BKNR) fehlt.')
partner = company.partner_id
# Zeilen werden je AN erfasst (mandantenseitige Fakten);
# der Lauf prüft die Erstellvorschriften und erzeugt die
# Sätze.
for line in meldung.line_ids:
employee = line.employee_id
version = employee.version_id
if not (version and version.ssnid):
fehler.append(
'Mitarbeiter %s ohne Versicherungsnummer.'
% employee.name)
continue
if not employee.birthday:
fehler.append(
'Mitarbeiter %s ohne Geburtsdatum.'
% employee.name)
continue
altersstand = meldung.jahr - employee.birthday.year
ist_frau = (version.sex == 'female')
grenze = 35 if ist_frau else 40
if altersstand < grenze:
warnungen.append(
'%s: %d Jahre am 31. 12. %d — unter der '
'Altersgrenze (%d %s); Meldung entfällt bzw. '
'Zeiten nur bei Begründung (lb-swa-05).'
% (employee.name, altersstand, meldung.jahr,
grenze, 'F' if ist_frau else 'M'))
if line.taetigkeit_ids.filtered(
lambda t: t.ziffer == 3):
warnungen.append(
'%s: Z 3 (chemisch/physikalisch) ist nicht '
'meldepflichtig (MdE ≥ 10 %% wird erst '
'nachträglich festgestellt — lb-swa-05).'
% employee.name)
if line.taetigkeit_ids.filtered(
lambda t: t.ziffer == 6):
warnungen.append(
'%s: Z 6 (Pflege) nur bei bekannter '
'Pflegegeldeinstufung ab Stufe 3 melden.'
% employee.name)
if fehler:
meldung.protokoll = 'FEHLER:\n%s' % '\n'.join(fehler)
raise UserError(
'Schwerarbeitsmeldungs-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler))
if meldung.line_ids:
heute = date.today()
jetzt = datetime.now()
datensaetze = []
for line in meldung.line_ids:
employee = line.employee_id
version = employee.version_id
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + SCHWERARBEIT_SATZ, 800,
bezeichnung='SM %s' % employee.name)
satz.setze('SART', '65')
satz.setze('VSTR', (bknr or '')[:2])
satz.setze('BKNR', bknr)
satz.setze('DGNA', (company.name or '')[:70])
satz.setze('WOBD', 'EMP-%d' % employee.id)
satz.setze('DKFZ', 'A')
satz.setze('DPLZ', (partner.zip or '')[:9])
satz.setze('DORT', (partner.city or '')[:40])
satz.setze('DSTR', (partner.street or '')[:50])
satz.setze('VSNR', line.versicherungsnummer)
satz.setze_datum('GEBD', employee.birthday)
familienname, vorname = l10n_at_namen(employee)
satz.setze('FANA', (familienname or '')[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze('JAHR', meldung.jahr)
for nummer, taetigkeit in enumerate(
line.taetigkeit_ids.sorted('sequence')[:26],
start=1):
satz.setze('TART_%d' % nummer,
'%02d' % taetigkeit.ziffer)
if taetigkeit.von:
satz.setze('TVON_%d' % nummer,
taetigkeit.von.strftime('%d%m'))
if taetigkeit.bis:
satz.setze('TBIS_%d' % nummer,
taetigkeit.bis.strftime('%d%m'))
satz.setze('REFN', 'SM-%d-%d-%d' % (
company.id, meldung.jahr, employee.id))
datensaetze.append(satz)
bestand = L10nAtEldaBestand(
'SM', (bknr or '')[:2], '02',
hersteller=meldung._l10n_at_hersteller(),
datensaetze=datensaetze)
datei = L10nAtEldaDatei([bestand])
meldung.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'schwerarbeit_%d.txt' % meldung.jahr,
})
meldung.protokoll = 'WARNUNGEN:\n%s' % (
warnungen and '\n'.join(warnungen) or '')
meldung.state = 'done'
return True
@@ -32,4 +32,15 @@ access_l10n_at_payroll_tasy_zuord_manager,L10n AT Payroll TASY-Zuordnung Manager
access_l10n_at_payroll_tasy_zuabschlag_user,L10n AT Payroll TASY-Zuabschlag User,model_l10n_at_payroll_tasy_zuabschlag,hr_payroll.group_hr_payroll_user,1,0,0,0 access_l10n_at_payroll_tasy_zuabschlag_user,L10n AT Payroll TASY-Zuabschlag User,model_l10n_at_payroll_tasy_zuabschlag,hr_payroll.group_hr_payroll_user,1,0,0,0
access_l10n_at_payroll_tasy_zuabschlag_manager,L10n AT Payroll TASY-Zuabschlag Manager,model_l10n_at_payroll_tasy_zuabschlag,hr_payroll.group_hr_payroll_manager,1,1,1,1 access_l10n_at_payroll_tasy_zuabschlag_manager,L10n AT Payroll TASY-Zuabschlag Manager,model_l10n_at_payroll_tasy_zuabschlag,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_tasy_zuabschlag_steuerung_user,L10n AT Payroll TASY-Zuabschlag-Steuerung User,model_l10n_at_payroll_tasy_zuabschlag_steuerung,hr_payroll.group_hr_payroll_user,1,0,0,0 access_l10n_at_payroll_tasy_zuabschlag_steuerung_user,L10n AT Payroll TASY-Zuabschlag-Steuerung User,model_l10n_at_payroll_tasy_zuabschlag_steuerung,hr_payroll.group_hr_payroll_user,1,0,0,0
access_l10n_at_payroll_tasy_zuabschlag_steuerung_manager,L10n AT Payroll TASY-Zuabschlag-Steuerung Manager,model_l10n_at_payroll_tasy_zuabschlag_steuerung,hr_payroll.group_hr_payroll_manager,1,1,1,1 access_l10n_at_payroll_tasy_zuabschlag_steuerung_manager,L10n AT Payroll TASY-Zuabschlag-Steuerung Manager,model_l10n_at_payroll_tasy_zuabschlag_steuerung,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_l17_user,L10n AT Payroll L17 User,model_l10n_at_payroll_l17,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_l17_manager,L10n AT Payroll L17 Manager,model_l10n_at_payroll_l17,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_l17_line_user,L10n AT Payroll L17-Line User,model_l10n_at_payroll_l17_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_m109_user,L10n AT Payroll Mitteilung109 User,model_l10n_at_payroll_mitteilung109,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_m109_manager,L10n AT Payroll Mitteilung109 Manager,model_l10n_at_payroll_mitteilung109,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_m109_line_user,L10n AT Payroll Mitteilung109-Line User,model_l10n_at_payroll_mitteilung109_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_m109_np_user,L10n AT Payroll Mitteilung109-NP User,model_l10n_at_payroll_mitteilung109_np,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_sm_user,L10n AT Payroll SM User,model_l10n_at_payroll_schwerarbeitsmeldung,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_sm_manager,L10n AT Payroll SM Manager,model_l10n_at_payroll_schwerarbeitsmeldung,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_sm_line_user,L10n AT Payroll SM-Line User,model_l10n_at_payroll_schwerarbeitsmeldung_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_sm_taetigkeit_user,L10n AT Payroll SM-Taetigkeit User,model_l10n_at_payroll_schwerarbeitsmeldung_taetigkeit,hr_payroll.group_hr_payroll_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
32 access_l10n_at_payroll_tasy_zuabschlag_user L10n AT Payroll TASY-Zuabschlag User model_l10n_at_payroll_tasy_zuabschlag hr_payroll.group_hr_payroll_user 1 0 0 0
33 access_l10n_at_payroll_tasy_zuabschlag_manager L10n AT Payroll TASY-Zuabschlag Manager model_l10n_at_payroll_tasy_zuabschlag hr_payroll.group_hr_payroll_manager 1 1 1 1
34 access_l10n_at_payroll_tasy_zuabschlag_steuerung_user L10n AT Payroll TASY-Zuabschlag-Steuerung User model_l10n_at_payroll_tasy_zuabschlag_steuerung hr_payroll.group_hr_payroll_user 1 0 0 0
35 access_l10n_at_payroll_tasy_zuabschlag_steuerung_manager L10n AT Payroll TASY-Zuabschlag-Steuerung Manager model_l10n_at_payroll_tasy_zuabschlag_steuerung hr_payroll.group_hr_payroll_manager 1 1 1 1
36 access_l10n_at_payroll_l17_user L10n AT Payroll L17 User model_l10n_at_payroll_l17 hr_payroll.group_hr_payroll_user 1 1 1 0
37 access_l10n_at_payroll_l17_manager L10n AT Payroll L17 Manager model_l10n_at_payroll_l17 hr_payroll.group_hr_payroll_manager 1 1 1 1
38 access_l10n_at_payroll_l17_line_user L10n AT Payroll L17-Line User model_l10n_at_payroll_l17_line hr_payroll.group_hr_payroll_user 1 1 1 1
39 access_l10n_at_payroll_m109_user L10n AT Payroll Mitteilung109 User model_l10n_at_payroll_mitteilung109 hr_payroll.group_hr_payroll_user 1 1 1 0
40 access_l10n_at_payroll_m109_manager L10n AT Payroll Mitteilung109 Manager model_l10n_at_payroll_mitteilung109 hr_payroll.group_hr_payroll_manager 1 1 1 1
41 access_l10n_at_payroll_m109_line_user L10n AT Payroll Mitteilung109-Line User model_l10n_at_payroll_mitteilung109_line hr_payroll.group_hr_payroll_user 1 1 1 1
42 access_l10n_at_payroll_m109_np_user L10n AT Payroll Mitteilung109-NP User model_l10n_at_payroll_mitteilung109_np hr_payroll.group_hr_payroll_user 1 1 1 1
43 access_l10n_at_payroll_sm_user L10n AT Payroll SM User model_l10n_at_payroll_schwerarbeitsmeldung hr_payroll.group_hr_payroll_user 1 1 1 0
44 access_l10n_at_payroll_sm_manager L10n AT Payroll SM Manager model_l10n_at_payroll_schwerarbeitsmeldung hr_payroll.group_hr_payroll_manager 1 1 1 1
45 access_l10n_at_payroll_sm_line_user L10n AT Payroll SM-Line User model_l10n_at_payroll_schwerarbeitsmeldung_line hr_payroll.group_hr_payroll_user 1 1 1 1
46 access_l10n_at_payroll_sm_taetigkeit_user L10n AT Payroll SM-Taetigkeit User model_l10n_at_payroll_schwerarbeitsmeldung_taetigkeit hr_payroll.group_hr_payroll_user 1 1 1 1
@@ -4,4 +4,5 @@ from . import test_tasy_katalog
from . import test_lohnsteuer from . import test_lohnsteuer
from . import test_sozialversicherung from . import test_sozialversicherung
from . import test_elda_l16 from . import test_elda_l16
from . import test_elda_l17
from . import test_gp8 from . import test_gp8
@@ -0,0 +1,216 @@
# 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)
@@ -0,0 +1,227 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- AP17: Lohnnachweis/Lohnbescheinigung L17 (A1) und Finanz- -->
<!-- Mitteilungen § 109a (W1)/§ 109b (B1). Menü unter dem -->
<!-- Meldewesen (ELDA/FinOnl). -->
<!-- ============================================================ -->
<record id="l17_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.l17.list</field>
<field name="model">l10n.at.payroll.l17</field>
<field name="arch" type="xml">
<list string="Lohnnachweis/Lohnbescheinigung (L17)">
<field name="jahr"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="l17_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.l17.form</field>
<field name="model">l10n.at.payroll.l17</field>
<field name="arch" type="xml">
<form string="L17 — Lohnnachweis/Lohnbescheinigung">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (A1 je LZP)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="jahr"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="A1-Sätze (je LZP)" name="linien">
<field name="line_ids">
<list editable="bottom">
<field name="employee_name"/>
<field name="date_from"/>
<field name="date_to"/>
<field name="versicherungsnummer"/>
<field name="kz350"/>
<field name="kz351" optional="show"/>
<field name="kz352" optional="show"/>
<field name="kz354" optional="hide"/>
<field name="kz357" optional="show"/>
<field name="kz347" optional="hide"/>
<field name="hota" optional="hide"/>
<field name="hopa" optional="hide"/>
</list>
</field>
</page>
<page string="Datei" name="datei">
<group>
<field name="datei" filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
</page>
<page string="Prüfprotokoll" name="protokoll">
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_l17" model="ir.actions.act_window">
<field name="name">Lohnnachweis L17</field>
<field name="res_model">l10n.at.payroll.l17</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_l10n_at_payroll_l17"
name="Lohnnachweis L17 (A1)"
parent="menu_l10n_at_payroll_meldewesen"
action="action_l17" sequence="12"/>
<record id="mitteilung109_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mitteilung109.list</field>
<field name="model">l10n.at.payroll.mitteilung109</field>
<field name="arch" type="xml">
<list string="Finanz-Mitteilungen § 109a/§ 109b">
<field name="jahr"/>
<field name="art"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="mitteilung109_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mitteilung109.form</field>
<field name="model">l10n.at.payroll.mitteilung109</field>
<field name="arch" type="xml">
<form string="Finanz-Mitteilungen § 109a/§ 109b">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (Schwellen, Datei)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="jahr"/>
<field name="art"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Mitteilungssätze" name="linien">
<field name="line_ids" context="{'default_art': art}">
<list editable="bottom"
open_form_view="1">
<field name="name_partner"/>
<field name="name"/>
<field name="versicherungsnummer"/>
<field name="steuernummer"
optional="show"/>
<field name="entgelt"/>
<field name="umsatzsteuer"
optional="show"/>
<field name="gemeldet"/>
</list>
</field>
</page>
<page string="Datei" name="datei">
<group>
<field name="datei" filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
</page>
<page string="Prüfprotokoll" name="protokoll">
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="mitteilung109_line_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mitteilung109.line.form</field>
<field name="model">l10n.at.payroll.mitteilung109.line</field>
<field name="arch" type="xml">
<form string="Finanz-Mitteilung (§ 109a/§ 109b)">
<sheet>
<group>
<group>
<field name="name_partner"/>
<field name="name"/>
<field name="versicherungsnummer"/>
<field name="steuernummer"/>
<field name="uid"/>
</group>
<group>
<field name="adresse"/>
<field name="landeskennung"/>
<field name="plz"/>
<field name="ort"/>
</group>
</group>
<group>
<group>
<field name="entgelt"/>
<field name="umsatzsteuer"/>
<field name="dn_sv_anteil"/>
<field name="bmv_beitrag"/>
</group>
<group>
<field name="art_leistung"/>
<field name="al31"/>
<field name="al32"/>
<field name="al33"/>
<field name="ziellaender"/>
<field name="ausnahme_grund"/>
<field name="korr"/>
<field name="gemeldet"/>
</group>
</group>
<group string="Maßgeblich auftretende natürliche Personen (max. 3)">
<field name="np_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="versicherungsnummer"/>
<field name="steuernummer"/>
<field name="uid" optional="hide"/>
</list>
</field>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_mitteilung109" model="ir.actions.act_window">
<field name="name">Finanz-Mitteilungen § 109a/§ 109b</field>
<field name="res_model">l10n.at.payroll.mitteilung109</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_l10n_at_payroll_m109"
name="Mitteilungen § 109a/§ 109b"
parent="menu_l10n_at_payroll_meldewesen"
action="action_mitteilung109" sequence="14"/>
</odoo>
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP18: Schwerarbeitsmeldung (ASchV § 5 / § 74 Abs 3 ASVG; -->
<!-- DM-ORG E.22, BEST SM, SART 65/66). -->
<record id="sm_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.schwerarbeitsmeldung.list</field>
<field name="model">l10n.at.payroll.schwerarbeitsmeldung</field>
<field name="arch" type="xml">
<list string="Schwerarbeitsmeldung (ASchV)">
<field name="jahr"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="sm_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.schwerarbeitsmeldung.form</field>
<field name="model">l10n.at.payroll.schwerarbeitsmeldung</field>
<field name="arch" type="xml">
<form string="Schwerarbeitsmeldung (§ 5 ASchV)">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (SART 65)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="jahr"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Meldesätze" name="linien">
<field name="line_ids">
<list editable="bottom"
open_form_view="1">
<field name="employee_name"/>
<field name="versicherungsnummer"/>
</list>
</field>
</page>
<page string="Datei" name="datei">
<group>
<field name="datei" filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
</page>
<page string="Prüfprotokoll" name="protokoll">
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="sm_line_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.schwerarbeitsmeldung.line.form</field>
<field name="model">l10n.at.payroll.schwerarbeitsmeldung.line</field>
<field name="arch" type="xml">
<form string="Schwerarbeitsmeldung je AN">
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="versicherungsnummer"/>
</group>
</group>
<group string="Tätigkeitsblöcke (ASchV § 1 Ziffer, von/bis)">
<field name="taetigkeit_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="ziffer"/>
<field name="von"/>
<field name="bis"/>
</list>
</field>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_schwerarbeitsmeldung" model="ir.actions.act_window">
<field name="name">Schwerarbeitsmeldung</field>
<field name="res_model">l10n.at.payroll.schwerarbeitsmeldung</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_l10n_at_payroll_sm"
name="Schwerarbeitsmeldung (SM)"
parent="menu_l10n_at_payroll_meldewesen"
action="action_schwerarbeitsmeldung" sequence="16"/>
</odoo>
@@ -61,4 +61,11 @@
<field name="name">Reisekosten — Nettoausgleich Überhang</field> <field name="name">Reisekosten — Nettoausgleich Überhang</field>
<field name="code">ATP_REISE_NETTOAUSGLEICH</field> <field name="code">ATP_REISE_NETTOAUSGLEICH</field>
</record> </record>
<!-- AP19: Präsenz-/Ausbildungs-/Zivildienst-Tage (§ 7 Abs 1/2
BMSVG) für die fiktive BV-Bemessungsgrundlage (at_bv_fiktiv_kbg). -->
<record id="input_type_atp_prd_tage" model="hr.payslip.input.type">
<field name="name">Präsenz-/Ausbildungs-/Zivildienst — Tage</field>
<field name="code">ATP_PRD_TAGE</field>
<field name="is_quantity" eval="True"/>
</record>
</odoo> </odoo>
@@ -24,4 +24,17 @@
<field name="date_from">2026-01-01</field> <field name="date_from">2026-01-01</field>
<field name="parameter_value">{'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}}</field> <field name="parameter_value">{'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}}</field>
</record> </record>
<!-- AP19: fiktive BG Präsenz-/Ausbildungs-/Zivildienst —
KBGG § 3 Abs 1 idF vor BGBl I 53/2016: 2026: 435,90 €
(lb-vor-04, Stand 2026-08); BMSVG § 7 Abs 1/2. -->
<record id="hr_rule_parameter_at_bv_fiktiv_kbg" model="hr.rule.parameter">
<field name="name">BMSVG § 7 — fiktive BG KBGG-Basis</field>
<field name="code">at_bv_fiktiv_kbg</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_bv_fiktiv_kbg" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_bv_fiktiv_kbg"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">435.90</field>
</record>
</odoo> </odoo>
@@ -22,6 +22,17 @@
<field name="l10n_at_nacht_sv" eval="True"/> <field name="l10n_at_nacht_sv" eval="True"/>
</record> </record>
<!-- AP18: Nachtschwerarbeit-Tage (NSchG Art VII iVm Art XI Abs 6; -->
<!-- mandantenseitige Katalog-Klassifikation ASchV/NSchG — Zähler- -->
<!-- grundlage für den Nachtschwerarbeitsmonat) -->
<record id="wett_nachtschwer" model="hr.work.entry.type">
<field name="name">Nachtschwerarbeit (Tag)</field>
<field name="color">24</field>
<field name="display_code">NSW</field>
<field name="code">ATP_NACHTSCHWER</field>
<field name="country_id" ref="base.at"/>
</record>
<!-- Überstunden --> <!-- Überstunden -->
<record id="wett_ue_std" model="hr.work.entry.type"> <record id="wett_ue_std" model="hr.work.entry.type">
<field name="name">Überstunde Tag (50 %)</field> <field name="name">Überstunde Tag (50 %)</field>
@@ -14,6 +14,7 @@ from . import buchungsbeleg
from . import wien_dag from . import wien_dag
from . import efzg from . import efzg
from . import mutterschutz from . import mutterschutz
from . import nachtschwerarbeit
from . import karenz from . import karenz
from . import aeb from . import aeb
from . import pfaendung from . import pfaendung
@@ -222,9 +222,11 @@ class HrPayslip(models.Model):
# AP4: fiktive BG während des Krankengeldbezugs (§ 7 Abs 3 # AP4: fiktive BG während des Krankengeldbezugs (§ 7 Abs 3
# BMSVG) und des Wochengeldbezugs (§ 7 Abs 4, AP7) — nur # BMSVG) und des Wochengeldbezugs (§ 7 Abs 4, AP7) — nur
# in beitragspflichtigen Läufen (Erstfenster-Teilmonate: # in beitragspflichtigen Läufen (Erstfenster-Teilmonate:
# dokumentierter Randfall). # dokumentierter Randfall). AP18/AP19: Präsenz-/Ausbildungs-
# und Zivildienst-Fiktion (§ 7 Abs 1/2, ATP_PRD_TAGE).
return basis + self._at_bv_fiktiv_krankengeld() \ return basis + self._at_bv_fiktiv_krankengeld() \
+ self._at_bv_fiktiv_wochengeld() + self._at_bv_fiktiv_wochengeld() \
+ self._at_bv_fiktiv_prasenzenzdienst()
if pflichtig_ab > self.date_to: if pflichtig_ab > self.date_to:
# LZP liegt zur Gänze im beitragsfreien Erstfenster. # LZP liegt zur Gänze im beitragsfreien Erstfenster.
return 0.0 return 0.0
@@ -235,6 +237,33 @@ class HrPayslip(models.Model):
return 0.0 return 0.0
return basis * tage / max(self._l10n_at_lohnsteuertage(), 1) return basis * tage / max(self._l10n_at_lohnsteuertage(), 1)
def _at_bv_fiktiv_prasenzenzdienst(self):
"""Fiktive BV-Grundlage während des Präsenz-/Ausbildungs-
und Zivildienstes (§ 7 Abs 1/2 BMSVG; RIS-Verifikation lokal
BMSVG-Volltext 2025-07, lb-vor-04): 1,53 % der fiktiven BG in
Höhe des KBGG § 3 Abs 1 idF vor BGBl I 53/2016 2026: 435,90
(Parameter at_bv_fiktiv_kbg, Stand lb-vor-04) aliquot auf
die Dienst-Tage je Kalendertage des LZP. >12 Monate
Zeitsoldat/Auslandseinsatz-/Ausbildungsdienst: AG-Beitrag
entfällt (Bund zahlt) mandantenseitig berücksichtigen,
Eingabe nicht setzen."""
self.ensure_one()
tage = 0
for eingabe in self.input_line_ids:
if eingabe.input_type_id.code == 'ATP_PRD_TAGE' \
and eingabe.amount:
tage += int(eingabe.amount)
if not tage:
return 0.0
kbg = float(self.env['hr.rule.parameter']
._get_parameter_from_code(
'at_bv_fiktiv_kbg', self.date_from,
raise_if_not_found=False) or 0.0)
if not kbg:
return 0.0
monatstage = (self.date_to - self.date_from).days + 1
return kbg * min(tage, monatstage) / float(monatstage)
def _at_bv_basis_sonst(self, result_rules=None): def _at_bv_basis_sonst(self, result_rules=None):
"""BV-Grundlage sonstiger Bezüge (Sonderzahlungen & Co.): """BV-Grundlage sonstiger Bezüge (Sonderzahlungen & Co.):
ungecappt (kein HBG-/Jahresdeckel, § 6 Abs 5) und ohne ungecappt (kein HBG-/Jahresdeckel, § 6 Abs 5) und ohne
@@ -134,6 +134,30 @@ class HrVersion(models.Model):
version.erfahrungsstufe = \ version.erfahrungsstufe = \
version.l10n_at_vwgj_stufe_vorschlag version.l10n_at_vwgj_stufe_vorschlag
l10n_at_status = fields.Selection(
[('an', 'Arbeitnehmer'),
('organ_minder', 'Organ ≤ 25 %% — SV-pflichtig'),
('organ_mehr', 'Organ ≥ 25 %% — kein SV-Verhältnis')],
string='AT Status (Organ/Geschäftsführung)',
default='an', required=True,
groups="hr_payroll.group_hr_payroll_user",
help='AP19 (lb-vst-03, lb-gsf-02/03/05): vertragsrechtlich freie '
'Dienstverhältnisse von Organnen. Organ ≥ 25 % Beteiligung: '
'kein SV-Verhältnis (§ 4 Abs 2 ASVG-Ausnahme — kein SVDN/SVDG, '
'kein mBGM, LSt normal); Organ ≤ 25 %: pflichtversichert wie '
'Dienstnehmer (§ 4 Abs 2 dritter Satz ASVG). '
'Mandanten-Entscheid je Fall; Rechtsberatung vor Umstellung.')
l10n_at_ferialpraktikant = fields.Boolean(
string='AT Ferialpraktikant/Praktikum',
groups="hr_payroll.group_hr_payroll_user",
help='AP19 (lb-pra-01…04): Ferialpraktikum/Volontariat. '
'SV/LSt laufen über die normale Entgelt-Logik (§ 4 Abs 2 '
'ASVG: unter GFG nur UV; Volontariat ohne Taschengeld: '
'UV-Teilversicherung § 8 Abs 1 Z 3 lit c — Meldewesen, '
'nicht Entgeltrechnung). BMSVG: § 6 nennt keinen '
'Ferialpraktikanten-Ausschluss — Beitrag wie sonstig '
'(Erstfenster).')
class HrEmployee(models.Model): class HrEmployee(models.Model):
_inherit = 'hr.employee' _inherit = 'hr.employee'
+189 -59
View File
@@ -300,6 +300,47 @@ class L10nAtPayrollMbgm(models.Model):
positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV')) positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV'))
return positionen return positionen
def _l10n_at_gfb_positionen(self, slip):
"""AP17: Verrechnungspositionen für geringfügig Beschäftigte
(BSGR B010/B030): T01 = § 53a-UV-Pauschalbeitrag (Engine:
``_l10n_at_svdg_uv_geringfuegig`` 2026 6,50 /Monat, abgeleitet
aus aBG 591,29 × 1,1 %; § 51 Abs 2 ASVG), V01 = BMSVG-Beitrag
1,53 % (Abfertigungsbeitrag § 5 Abs 2 BMSVG, Engine-Position
BV). Keine AV-/IE-Anteile, keine Abschläge (A01A03/A09/A10/
A15), kein T02 GfB-SZ sind beitragsfrei (§ 49 Abs 3;
lb-bes-06)."""
self.ensure_one()
uv_pauschale = slip._l10n_at_svdg_uv_geringfuegig()
positionen = []
if uv_pauschale:
positionen.append(('T01', 0.0, uv_pauschale, 'AB'))
bv_basis = (slip._at_bv_basis_laufen()
+ slip._at_bv_basis_sonst())
if bv_basis > 0:
positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV'))
return positionen
def _l10n_at_bv_jahresabrechnung_gfb(self, jahr):
"""Jährliche Abrechnung GfB (§ 58 Abs 8 ASVG iVm § 6 Abs 2a
BMSVG): Summe der JAGB-mBGM-Beiträge des Kalenderjahres und
der 2,5 vH-Zuschlag (Z04 BV-Zuschlag bei jährlicher Zahlung,
D.60) Fälligkeit bis 15. Jänner des Folgejahres."""
self.ensure_one()
deklarationen = self.search([
('company_id', '=', self.company_id.id),
('beitragsmonat', '>=', date(jahr, 1, 1)),
('beitragsmonat', '<=', date(jahr, 12, 31)),
('art', '=', 'MB'),
('state', '=', 'done'),
])
summe = sum(
line.vsum
for deklaration in deklarationen
for line in deklaration.line_ids.filtered('jagb'))
zuschlag = round(summe * 0.025, 2)
return {'summe': summe, 'zuschlag': zuschlag,
'gesamt': round(summe + zuschlag, 2)}
def action_erzeugen(self): def action_erzeugen(self):
for deklaration in self: for deklaration in self:
fehler = [] fehler = []
@@ -310,9 +351,17 @@ class L10nAtPayrollMbgm(models.Model):
fehler.append('Beitragskontonummer (BKNR) fehlt.') fehler.append('Beitragskontonummer (BKNR) fehlt.')
vstr = (bknr or '')[:2] vstr = (bknr or '')[:2]
deklaration.line_ids.unlink() deklaration.line_ids.unlink()
datensaetze = [] # AP17: mBGM-Pakete — 'normal' (JAGB 'N') und — bei
summe_gesamt = 0 # jährlicher Abrechnung GfB (§ 58 Abs 8 ASVG iVm § 6
anzahl = 0 # Abs 2a BMSVG) — ein separates JAGB-Paket (E.32.2.2.1/D.52)
# für die GfB-Beitragsgrundlagen je Kalendermonat.
paket_gruppen = {
'normal': {'saetze': [], 'summe': 0.0, 'anzahl': 0},
'jagb': {'saetze': [], 'summe': 0.0, 'anzahl': 0},
}
jagb_flag = company.l10n_at_gfb_jaehrliche_abrechnung
if deklaration.art == 'ST':
jagb_flag = False
if deklaration.art != 'FZ': if deklaration.art != 'FZ':
slips = deklaration._l10n_at_slips() slips = deklaration._l10n_at_slips()
nach_an = {} nach_an = {}
@@ -325,6 +374,13 @@ class L10nAtPayrollMbgm(models.Model):
'Mitarbeiter %s ohne Versicherungsnummer.' 'Mitarbeiter %s ohne Versicherungsnummer.'
% employee.name) % employee.name)
continue continue
if getattr(version, 'l10n_at_status', 'an') \
== 'organ_mehr':
warnungen.append(
'Mitarbeiter %s: Organ ≥ 25 %% (kein SV-'
'Verhältnis, AP19) — keine mBGM; LSt/Lohnzettel '
'unverändert.' % employee.name)
continue
if len(employee_slips) > 1: if len(employee_slips) > 1:
fehler.append( fehler.append(
'Mitarbeiter %s mit mehreren Läufen im ' 'Mitarbeiter %s mit mehreren Läufen im '
@@ -356,7 +412,8 @@ class L10nAtPayrollMbgm(models.Model):
storno_satz.setze('VSNR', version.ssnid) storno_satz.setze('VSNR', version.ssnid)
storno_satz.setze('VSUM', abs(original.vsum), storno_satz.setze('VSUM', abs(original.vsum),
cent=True) cent=True)
datensaetze.append(storno_satz) paket_gruppen['normal']['saetze'].append(
storno_satz)
deklaration.env['l10n.at.payroll.mbgm.line']\ deklaration.env['l10n.at.payroll.mbgm.line']\
.create({ .create({
'mbgm_id': deklaration.id, 'mbgm_id': deklaration.id,
@@ -366,43 +423,63 @@ class L10nAtPayrollMbgm(models.Model):
'refw': refw, 'refw': refw,
'vsum': original.vsum, 'vsum': original.vsum,
}) })
summe_gesamt += original.vsum paket_gruppen['normal']['summe'] += original.vsum
anzahl += 1 paket_gruppen['normal']['anzahl'] += 1
continue continue
bsgr = deklaration._l10n_at_bsgr(version, slip) bsgr = deklaration._l10n_at_bsgr(version, slip)
tarif = deklaration._l10n_at_tarif(bsgr, version) if slip.l10n_at_geringfuegig:
if not tarif: # GfB (AP17): eigenes Beitragsset — § 53a-UV-
# Pauschale (T01) + BMSVG 1,53 % (V01), keine
# AV-/IE-/Abschlagspositionen, kein T02 (GfB-SZ
# beitragsfrei, § 49 Abs 3).
positionen = deklaration._l10n_at_gfb_positionen(
slip)
if not positionen:
fehler.append(
'Mitarbeiter %s: GfB-Beitragssätze fehlen '
'(§-53a-Parameter am Beitragsmonat).'
% employee.name)
continue
else:
tarif = deklaration._l10n_at_tarif(bsgr, version)
if not tarif:
fehler.append(
'Mitarbeiter %s: kein Tarifsatz für BSGR '
'%s (weder TASY-Katalog noch Parameter '
'at_mbgm_tarif am Beitragsmonat).'
% (employee.name, bsgr))
continue
positionen = deklaration._l10n_at_positionen(
slip, tarif)
if version.l10n_at_fallweise:
fehler.append( fehler.append(
'Mitarbeiter %s: kein Tarifsatz für BSGR ' 'Mitarbeiter %s: fallweise mBGM (G3/G4 mit '
'%s (weder TASY-Katalog noch Parameter ' 'tagesgenauen Tarifblöcken T2/T5) ist ein '
'at_mbgm_tarif am Beitragsmonat).' 'dokumentierter Rest (BSGR %s).'
% (employee.name, bsgr)) % (employee.name, bsgr))
continue continue
if slip.l10n_at_geringfuegig \
or version.l10n_at_fallweise:
fehler.append(
'Mitarbeiter %s: geringfügige/fallweise '
'Beschäftigung in der mBGM ist ein '
'dokumentierter Rest (BSGR %s, Tarifsystem '
'verifizieren).' % (employee.name, bsgr))
continue
positionen = deklaration._l10n_at_positionen(
slip, tarif)
vsum = sum(rsum for _c, _s, rsum, _b in positionen) vsum = sum(rsum for _c, _s, rsum, _b in positionen)
# Konsistenz gegen die GP4-Payslip-Summen. # Konsistenz gegen die GP4-Payslip-Summen.
ist = (slip._l10n_at_svdn_gesamt() if slip.l10n_at_geringfuegig:
+ slip._l10n_at_svdg_gesamt() ist = (slip._l10n_at_svdg_uv_geringfuegig()
+ slip._l10n_at_svdn_sz() + slip._at_bv_beitrag())
+ sum( else:
slip._l10n_at_svdg_sz(k) ist = (slip._l10n_at_svdn_gesamt()
for k in ('kv', 'pv', 'av', 'uv', 'iesg')) + slip._l10n_at_svdg_gesamt()
+ slip._at_bv_beitrag()) + slip._l10n_at_svdn_sz()
+ sum(
slip._l10n_at_svdg_sz(k)
for k in ('kv', 'pv', 'av', 'uv', 'iesg'))
+ slip._at_bv_beitrag())
if abs(vsum - ist) > 0.10: if abs(vsum - ist) > 0.10:
warnungen.append( warnungen.append(
'Mitarbeiter %s: mBGM-VSUM %.2f weicht um ' 'Mitarbeiter %s: mBGM-VSUM %.2f weicht um '
'%.2f von den GP4-Beitragssummen ab ' '%.2f von den GP4-Beitragssummen ab '
'(Tarif-/Rundungsdifferenz prüfen).' '(Tarif-/Rundungsdifferenz prüfen).'
% (employee.name, vsum, vsum - ist)) % (employee.name, vsum, vsum - ist))
gruppe = (
'jagb' if (slip.l10n_at_geringfuegig and jagb_flag)
else 'normal')
deklaration.env['l10n.at.payroll.mbgm.line'].create({ deklaration.env['l10n.at.payroll.mbgm.line'].create({
'mbgm_id': deklaration.id, 'mbgm_id': deklaration.id,
'employee_id': employee.id, 'employee_id': employee.id,
@@ -413,46 +490,82 @@ class L10nAtPayrollMbgm(models.Model):
deklaration.beitragsmonat.strftime('%Y%m'), deklaration.beitragsmonat.strftime('%Y%m'),
employee.id), employee.id),
'vsum': vsum, 'vsum': vsum,
'jagb': gruppe == 'jagb',
}) })
datensaetze.extend( paket_gruppen[gruppe]['saetze'].extend(
deklaration._l10n_at_mbgm_datensaetze( deklaration._l10n_at_mbgm_datensaetze(
employee, version, bsgr, positionen, vsum)) employee, version, bsgr, positionen, vsum))
summe_gesamt += vsum paket_gruppen[gruppe]['summe'] += vsum
anzahl += 1 paket_gruppen[gruppe]['anzahl'] += 1
paket = L10nAtEldaSatz( datensaetze = []
L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326, for schluessel in ('normal', 'jagb'):
bezeichnung='mBGM-Paket') gruppe = paket_gruppen[schluessel]
paket.setze('SART', 'PS') if not gruppe['anzahl'] and not (
paket.setze('VSTR', vstr) deklaration.art == 'FZ'
paket.setze('REFP', 'MBGM-%d-%s' % ( and schluessel == 'normal'):
company.id, continue
deklaration.beitragsmonat.strftime('%Y%m'))) paket = L10nAtEldaSatz(
paket.setze('BKNR', bknr) L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326,
paket.setze('DGNA', company.name[:70]) bezeichnung='mBGM-Paket %s' % schluessel)
paket.setze('JAGB', 'N') paket.setze('SART', 'PS')
paket.setze('BZRM', deklaration.beitragsmonat.strftime( paket.setze('VSTR', vstr)
'%m%Y')) paket.setze('REFP', 'MBGM-%d-%s%s' % (
paket.setze('GSVZ', '+' if summe_gesamt >= 0 else '-') company.id,
paket.setze('GSUM', abs(summe_gesamt), cent=True) deklaration.beitragsmonat.strftime('%Y%m'),
paket.setze('ANZM', anzahl) '-JAGB' if schluessel == 'jagb' else ''))
datensaetze.insert(0, paket) paket.setze('BKNR', bknr)
paket.setze('DGNA', company.name[:70])
paket.setze('JAGB', 'J' if schluessel == 'jagb' else 'N')
paket.setze('BZRM', deklaration.beitragsmonat.strftime(
'%m%Y'))
paket.setze('GSVZ',
'+' if gruppe['summe'] >= 0 else '-')
paket.setze('GSUM', abs(gruppe['summe']), cent=True)
paket.setze('ANZM', gruppe['anzahl'])
datensaetze.append(paket)
datensaetze.extend(gruppe['saetze'])
if jagb_flag and paket_gruppen['jagb']['summe']:
warnungen.append(
'JAGB-Paket: jährliche Abrechnung der GfB-Beiträge '
'(§ 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG) — Fälligkeit '
'bis 15. Jänner des Folgejahres; der jährliche BV-'
'Beitragsbetrag ist um 2,5 %% Zuschlag zu erhöhen '
'(Summe mBGM-Jahr: siehe Protokoll der '
'Dezember-Abrechnung).')
if jagb_flag and deklaration.beitragsmonat.month == 12 \
and paket_gruppen['jagb']['summe']:
jahresabrechnung = deklaration\
._l10n_at_bv_jahresabrechnung_gfb(
deklaration.beitragsmonat.year)
warnungen.append(
'GfB-Jahresabrechnung %d: Beiträge %.2f € + '
'2,5 %% Zuschlag %.2f € (Z04) = %.2f €, fällig '
'bis 15. 1. %d.' % (
deklaration.beitragsmonat.year,
jahresabrechnung['summe'],
jahresabrechnung['zuschlag'],
jahresabrechnung['gesamt'],
deklaration.beitragsmonat.year + 1))
if fehler: if fehler:
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % ( deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
'\n'.join(fehler), '\n'.join(warnungen) or '') '\n'.join(fehler), '\n'.join(warnungen) or '')
raise UserError('mBGM-Lauf mit Fehlern abgebrochen:\n' raise UserError('mBGM-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler)) + '\n'.join(fehler))
bestand = L10nAtEldaBestand( if datensaetze:
'MB', vstr, '02', bestand = L10nAtEldaBestand(
hersteller={'name': 'odoo-at-payroll', 'kfz': 'A', 'MB', vstr, '02',
'plz': company.partner_id.zip, hersteller={'name': 'odoo-at-payroll', 'kfz': 'A',
'ort': company.partner_id.city, 'plz': company.partner_id.zip,
'strasse': company.partner_id.street}, 'ort': company.partner_id.city,
datensaetze=datensaetze) 'strasse': company.partner_id.street},
datei = L10nAtEldaDatei([bestand]) datensaetze=datensaetze)
datei = L10nAtEldaDatei([bestand])
deklaration.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'mbgm_%s.txt' % (
deklaration.beitragsmonat.strftime('%Y%m'),),
})
deklaration.write({ deklaration.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'mbgm_%s.txt' % (
deklaration.beitragsmonat.strftime('%Y%m'),),
'protokoll': 'WARNUNGEN:\n%s' % ( 'protokoll': 'WARNUNGEN:\n%s' % (
'\n'.join(warnungen) or ''), '\n'.join(warnungen) or ''),
'state': 'done', 'state': 'done',
@@ -525,6 +638,19 @@ class L10nAtPayrollMbgm(models.Model):
class ResCompany(models.Model): class ResCompany(models.Model):
_inherit = 'res.company' _inherit = 'res.company'
l10n_at_gfb_jaehrliche_abrechnung = fields.Boolean(
string='AT GfB: jährliche Abrechnung der Beiträge (JAGB)',
help='Vereinbarung nach § 58 Abs 8 ASVG iVm § 6 Abs 2a BMSVG: '
'die Abfertigungs-/BV-Beiträge aus geringfügigen '
'Beschäftigungsverhältnissen werden jährlich verrechnet. '
'mBGM-seitig je Kalendermonat ein eigenes Paket mit '
'JAGB = „J“ (DM-ORG D.52/E.32.2.2.1); Fälligkeit bis '
'15. Jänner des Folgejahres (unterjähriges AV-Ende: '
'bis 15. des Folgemonats). Bei jährlicher Zahlungsweise '
'sind zusätzlich 2,5 vH vom Beitrag zu leisten. '
'Änderung der Zahlungsweise nur zum Jahresende und dem '
'KV-Träger vor dem Beitragszeitraum zu melden.')
l10n_at_nschab = fields.Boolean( l10n_at_nschab = fields.Boolean(
string='AT Nachtschwerarbeitsbeitrags-Betrieb (mBGM-Fallback)', string='AT Nachtschwerarbeitsbeitrags-Betrieb (mBGM-Fallback)',
help='Betrieb mit Nachtschwerarbeit (ÖGK-Tarifsystem ' help='Betrieb mit Nachtschwerarbeit (ÖGK-Tarifsystem '
@@ -573,6 +699,10 @@ class L10nAtPayrollMbgmLine(models.Model):
readonly=True) readonly=True)
versicherungsnummer = fields.Char() versicherungsnummer = fields.Char()
bsgr = fields.Char(string='Beschäftigtengruppe') bsgr = fields.Char(string='Beschäftigtengruppe')
jagb = fields.Boolean(
string='JAGB-Paket (jährliche Abrechnung GfB)',
help='Zeile gehört zum mBGM-Paket für die jährliche Abrechnung '
'geringfügig Beschäftigter (DM-ORG D.52).')
refw = fields.Char(string='Referenzwert (REFW)') refw = fields.Char(string='Referenzwert (REFW)')
vsum = fields.Monetary( vsum = fields.Monetary(
string='VSUM', currency_field='currency_id') string='VSUM', currency_field='currency_id')
@@ -0,0 +1,102 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP18: Nachtschwerarbeitsmonat-Zähler (NSchG Art XI Abs 6) —
RIS-verifiziert 2026-09-16 (NOR40144608, RIS GesNr 10008502, BGBl
1981/354):
- ** 6 Arbeitstage** Nachtschwerarbeit im Kalendermonat Nachtschwer-
arbeitsmonat;
- **< 6 Tage**: Rückblick-Fenster Monat + 1 Vormonat **12** Tage bzw.
Monat + 2 Vormonate **18** Tage;
- (DZR > 3 Monate: Monat + 5 Vormonate 36 Tage dokumentierter Rest,
kein Durchrechnungszeitraum-Datenmodell);
- **Randmonat**: die 6 erwarteten Nachtschwerarbeitstage werden nur
deswegen nicht erreicht, weil die Arbeit nicht am Monats-Ersten begonnen
bzw. am Letzten geendet hat v1 mit Pro-rata-Proxy (Tage 6 × belegte
Monatskalendertage / Monatskalendertage, aufgerundet) implementiert
(Arbeitszeiteinteilung der Muster im Parallellauf verifizieren);
- Arbeitsunterbrechungen bleiben außer Betracht, solange die PV-Pflicht-
versicherung weiterbesteht.
Datenbasis: Work Entries des Typs ``ATP_NACHTSCHWER`` (mandantenseitige
Klassifikation der Nachtschwerarbeit nach NSchG Art VII Abs 2/4 bzw. ASchV
§ 1 die Katalog-Kriterien kann die Engine nicht ableiten). Abgleich mit
der E01-Belegung (mBGM-Ergb, AP0/TASY) als Protokoll-Hinweis in der
mBGM-Generierung."""
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models
_NACHTSCHWER_WET = 'ATP_NACHTSCHWER'
class HrVersion(models.Model):
_inherit = 'hr.version'
def _l10n_at_nachtschwer_tage(self, monat_erster, monat_letzter):
"""Tage mit Nachtschwerarbeit-WETs im (Teil-)Monat."""
self.ensure_one()
eintraege = self.env['hr.work.entry'].search([
('employee_id', '=', self.employee_id.id),
('date', '>=', monat_erster),
('date', '<=', monat_letzter),
('state', '!=', 'cancelled'),
('work_entry_type_id.code', '=', _NACHTSCHWER_WET),
])
return len({eintrag.date for eintrag in eintraege})
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
l10n_at_nachtschwerarbeitsmonat = fields.Boolean(
string='AT Nachtschwerarbeitsmonat',
compute='_compute_l10n_at_nachtschwerarbeitsmonat',
store=True,
help='NSchG Art XI Abs 6 (RIS NOR40144608): ≥ 6 Nachtschwerarbeits-'
'tage im Monat bzw. Rückblick-Fenster (12/18; 36 bei DZR > '
'3 Monate — dokumentierter Rest) bzw. Randmonat (Pro-rata-'
'Proxy ⚠). Grundlage NSchAB 3,80 % (Art XI Abs 3 iVm '
'Sistierung; TASY-ERGB E01).')
@api.depends('date_from', 'date_to', 'version_id')
def _compute_l10n_at_nachtschwerarbeitsmonat(self):
for slip in self:
slip.l10n_at_nachtschwerarbeitsmonat = \
slip._l10n_at_nachtschwerarbeitsmonat(slip.date_from)
def _l10n_at_nachtschwerarbeitsmonat(self, stichtag):
"""Art XI Abs 6: 6/12/18-Fenster + Randmonat-Proxy."""
self.ensure_one()
version = self.version_id
if not version:
return False
erster = stichtag.replace(day=1)
letzter = (erster + relativedelta(months=1)) \
- relativedelta(days=1)
monatstage = (letzter - erster).days + 1
tage = version._l10n_at_nachtschwer_tage(erster, letzter)
if tage >= 6:
return True
# Randmonat (Art XI Abs 6 letzter Satz): die sechs erwarteten
# Nachtschwerarbeitstage werden nur deswegen nicht erreicht,
# weil die Arbeit nicht am Monats-Ersten begonnen bzw. am
# Letzten geendet hat — v1 Pro-rata-Proxy.
beginn = max(erster, self.date_from)
ende = min(letzter, self.date_to)
if (beginn, ende) != (erster, letzter):
belegte = (ende - beginn).days + 1
erforderlich = int(-(-6 * belegte // monatstage)) # ceil
if tage >= erforderlich:
return True
# Rückblick-Fenster (12/18): Monat + 1 bzw. + 2 Vormonate.
tage_1 = version._l10n_at_nachtschwer_tage(
erster - relativedelta(months=1),
erster - relativedelta(days=1))
if tage + tage_1 >= 12:
return True
tage_2 = version._l10n_at_nachtschwer_tage(
erster - relativedelta(months=2),
erster - relativedelta(days=1))
if tage + tage_1 + tage_2 >= 18:
return True
return False
@@ -61,6 +61,18 @@ class HrVersionPrivate(models.Model):
class HrPayslip(models.Model): class HrPayslip(models.Model):
_inherit = 'hr.payslip' _inherit = 'hr.payslip'
def _l10n_at_sv_satz(self, seite, komponente, monat=None,
staffel_basis=None):
"""AP19: Organ ≥ 25 % Beteiligung — kein SV-Verhältnis
(lb-vst-03; § 4 Abs 2 ASVG-Ausnahme) alle Beitragssätze 0;
LSt läuft unverändert weiter."""
self.ensure_one()
if getattr(self.version_id, 'l10n_at_status', 'an') \
== 'organ_mehr':
return 0.0
return super()._l10n_at_sv_satz(
seite, komponente, monat=monat, staffel_basis=staffel_basis)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Arbeitstage (resource_calendar) — § 17 Abs 2 KV, user convention: # Arbeitstage (resource_calendar) — § 17 Abs 2 KV, user convention:
# Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender # Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender
@@ -19,3 +19,5 @@ from . import test_reisekosten_private
from . import test_pendler_private from . import test_pendler_private
from . import test_endabrechnung_private from . import test_endabrechnung_private
from . import test_karenz_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)
@@ -51,6 +51,7 @@
<field name="employee_name"/> <field name="employee_name"/>
<field name="versicherungsnummer"/> <field name="versicherungsnummer"/>
<field name="bsgr"/> <field name="bsgr"/>
<field name="jagb" optional="show"/>
<field name="refw"/> <field name="refw"/>
<field name="vsum"/> <field name="vsum"/>
</list> </list>
@@ -34,6 +34,7 @@
<field name="l10n_at_dienstgebergruppe" <field name="l10n_at_dienstgebergruppe"
placeholder="z. B. NBALG"/> placeholder="z. B. NBALG"/>
<field name="l10n_at_nschab"/> <field name="l10n_at_nschab"/>
<field name="l10n_at_gfb_jaehrliche_abrechnung"/>
</group> </group>
</field> </field>
</record> </record>