diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index f4317c8..f2e85f6 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,6 +2,93 @@ ## 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 1–3, 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, LKZ1–7, 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** (2026-09-16, uncommittet bis Nutzerfreigabe; Modul **19.0.1.0.0**): diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py index d62305d..c407cdb 100644 --- a/addons/l10n_at_hr_payroll/__manifest__.py +++ b/addons/l10n_at_hr_payroll/__manifest__.py @@ -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_sv_2026.xml', 'data/rule_parameters_2027.xml', + 'data/rule_parameters_109.xml', 'data/tasy_mapping.xml', 'data/hr_departure_reason_seed.xml', 'views/tasy_views.xml', 'views/tasy_katalog_views.xml', 'views/company_views.xml', 'views/meldewesen_views.xml', + 'views/meldewesen_finanz_views.xml', + 'views/schwerarbeitsmeldung_views.xml', 'views/fristen_views.xml', 'report/belege_reports.xml', 'report/jahresreports.xml', diff --git a/addons/l10n_at_hr_payroll/data/rule_parameters_109.xml b/addons/l10n_at_hr_payroll/data/rule_parameters_109.xml new file mode 100644 index 0000000..efe0c8e --- /dev/null +++ b/addons/l10n_at_hr_payroll/data/rule_parameters_109.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + AT § 109a/§ 109b EStG Schwellen + at_m109_schwellen + + + + + 2026-01-01 + {'m109a_leistung': 450.0, 'm109a_kumulativ': 900.0, 'm109b_jahr': 100000.0} + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/__init__.py b/addons/l10n_at_hr_payroll/models/__init__.py index 6a0e579..3d3d7b9 100644 --- a/addons/l10n_at_hr_payroll/models/__init__.py +++ b/addons/l10n_at_hr_payroll/models/__init__.py @@ -7,6 +7,9 @@ from . import elda from . import meldewesen from . import kind from . import lohnzettel_l16 +from . import lohnzettel_l17 +from . import mitteilung_109 +from . import schwerarbeitsmeldung from . import fristenkalender from . import jahresretro from . import mitteilungsfile diff --git a/addons/l10n_at_hr_payroll/models/fristenkalender.py b/addons/l10n_at_hr_payroll/models/fristenkalender.py index 1c9fa76..b3c4016 100644 --- a/addons/l10n_at_hr_payroll/models/fristenkalender.py +++ b/addons/l10n_at_hr_payroll/models/fristenkalender.py @@ -92,7 +92,38 @@ FRISTART_DEFINITIONEN = { 'hilfetext': 'Meldung der Honorare außerhalb von ' 'Dienstverhältnissen bis Ende Februar via ' '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': { 'name': 'Schwerarbeitsmeldung (ASchV § 5)', @@ -224,7 +255,7 @@ class L10nAtPayrollFrist(models.Model): # Jahresfristen nur am 1. Jänner (für das Vorjahr). if heute.month == 1: for art in ('l16', 'arbeitsstaette', 'paragraph_109a', - 'schwerarbeit'): + 'mitteilung_109b', 'l17', 'schwerarbeit'): monat = FRISTART_DEFINITIONEN[art]['monat'] tag = FRISTART_DEFINITIONEN[art]['tag_im_monat'] jahr_deadline = heute.year diff --git a/addons/l10n_at_hr_payroll/models/lohnzettel_l17.py b/addons/l10n_at_hr_payroll/models/lohnzettel_l17.py new file mode 100644 index 0000000..554674c --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/lohnzettel_l17.py @@ -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 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/mitteilung_109.py b/addons/l10n_at_hr_payroll/models/mitteilung_109.py new file mode 100644 index 0000000..a3bcc19 --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/mitteilung_109.py @@ -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 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/schwerarbeitsmeldung.py b/addons/l10n_at_hr_payroll/models/schwerarbeitsmeldung.py new file mode 100644 index 0000000..b691aa9 --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/schwerarbeitsmeldung.py @@ -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 1–26 (Z 1 Schicht-/Wechseldienst in Nachtarbeit 22–6 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änner–Ende 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 502–761) + (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 1–26 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 1–26.' % 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 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/security/ir.model.access.csv b/addons/l10n_at_hr_payroll/security/ir.model.access.csv index 1c2fd20..320120f 100644 --- a/addons/l10n_at_hr_payroll/security/ir.model.access.csv +++ b/addons/l10n_at_hr_payroll/security/ir.model.access.csv @@ -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_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_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 \ No newline at end of file +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 diff --git a/addons/l10n_at_hr_payroll/tests/__init__.py b/addons/l10n_at_hr_payroll/tests/__init__.py index 88b46e0..507fa5a 100644 --- a/addons/l10n_at_hr_payroll/tests/__init__.py +++ b/addons/l10n_at_hr_payroll/tests/__init__.py @@ -4,4 +4,5 @@ from . import test_tasy_katalog from . import test_lohnsteuer from . import test_sozialversicherung from . import test_elda_l16 +from . import test_elda_l17 from . import test_gp8 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/tests/test_elda_l17.py b/addons/l10n_at_hr_payroll/tests/test_elda_l17.py new file mode 100644 index 0000000..61f9fa9 --- /dev/null +++ b/addons/l10n_at_hr_payroll/tests/test_elda_l17.py @@ -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) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/views/meldewesen_finanz_views.xml b/addons/l10n_at_hr_payroll/views/meldewesen_finanz_views.xml new file mode 100644 index 0000000..1e356b6 --- /dev/null +++ b/addons/l10n_at_hr_payroll/views/meldewesen_finanz_views.xml @@ -0,0 +1,227 @@ + + + + + + + + + + l10n.at.payroll.l17.list + l10n.at.payroll.l17 + + + + + + + + + + + l10n.at.payroll.l17.form + l10n.at.payroll.l17 + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Lohnnachweis L17 + l10n.at.payroll.l17 + list,form + + + + + + l10n.at.payroll.mitteilung109.list + l10n.at.payroll.mitteilung109 + + + + + + + + + + + + l10n.at.payroll.mitteilung109.form + l10n.at.payroll.mitteilung109 + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + l10n.at.payroll.mitteilung109.line.form + l10n.at.payroll.mitteilung109.line + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Finanz-Mitteilungen § 109a/§ 109b + l10n.at.payroll.mitteilung109 + list,form + + + +
\ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/views/schwerarbeitsmeldung_views.xml b/addons/l10n_at_hr_payroll/views/schwerarbeitsmeldung_views.xml new file mode 100644 index 0000000..747bc57 --- /dev/null +++ b/addons/l10n_at_hr_payroll/views/schwerarbeitsmeldung_views.xml @@ -0,0 +1,104 @@ + + + + + + l10n.at.payroll.schwerarbeitsmeldung.list + l10n.at.payroll.schwerarbeitsmeldung + + + + + + + + + + + l10n.at.payroll.schwerarbeitsmeldung.form + l10n.at.payroll.schwerarbeitsmeldung + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + l10n.at.payroll.schwerarbeitsmeldung.line.form + l10n.at.payroll.schwerarbeitsmeldung.line + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + Schwerarbeitsmeldung + l10n.at.payroll.schwerarbeitsmeldung + list,form + + + +
\ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml index 71376a8..279ab33 100644 --- a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml @@ -61,4 +61,11 @@ Reisekosten — Nettoausgleich Überhang ATP_REISE_NETTOAUSGLEICH + + + Präsenz-/Ausbildungs-/Zivildienst — Tage + ATP_PRD_TAGE + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml b/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml index 54392a8..60c4d52 100644 --- a/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/rule_parameters_mbgm_private.xml @@ -24,4 +24,17 @@ 2026-01-01 {'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}} + + + BMSVG § 7 — fiktive BG KBGG-Basis + at_bv_fiktiv_kbg + + + + + 2026-01-01 + 435.90 + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml b/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml index 60cffb0..868b89a 100644 --- a/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/work_entry_types_private.xml @@ -22,6 +22,17 @@ + + + + + Nachtschwerarbeit (Tag) + 24 + NSW + ATP_NACHTSCHWER + + + Überstunde Tag (50 %) diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index 6c0cfb4..701bb02 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -14,6 +14,7 @@ from . import buchungsbeleg from . import wien_dag from . import efzg from . import mutterschutz +from . import nachtschwerarbeit from . import karenz from . import aeb from . import pfaendung diff --git a/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py b/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py index 87f8a87..e8ad2ba 100644 --- a/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py +++ b/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py @@ -222,9 +222,11 @@ class HrPayslip(models.Model): # AP4: fiktive BG während des Krankengeldbezugs (§ 7 Abs 3 # BMSVG) und des Wochengeldbezugs (§ 7 Abs 4, AP7) — nur # 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() \ - + self._at_bv_fiktiv_wochengeld() + + self._at_bv_fiktiv_wochengeld() \ + + self._at_bv_fiktiv_prasenzenzdienst() if pflichtig_ab > self.date_to: # LZP liegt zur Gänze im beitragsfreien Erstfenster. return 0.0 @@ -235,6 +237,33 @@ class HrPayslip(models.Model): return 0.0 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): """BV-Grundlage sonstiger Bezüge (Sonderzahlungen & Co.): ungecappt (kein HBG-/Jahresdeckel, § 6 Abs 5) und ohne diff --git a/addons/l10n_at_hr_payroll_private/models/hr_version_private.py b/addons/l10n_at_hr_payroll_private/models/hr_version_private.py index c6bd3d5..3a3812b 100644 --- a/addons/l10n_at_hr_payroll_private/models/hr_version_private.py +++ b/addons/l10n_at_hr_payroll_private/models/hr_version_private.py @@ -134,6 +134,30 @@ class HrVersion(models.Model): version.erfahrungsstufe = \ 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): _inherit = 'hr.employee' diff --git a/addons/l10n_at_hr_payroll_private/models/mbgm.py b/addons/l10n_at_hr_payroll_private/models/mbgm.py index 6f96f48..5de3cb0 100644 --- a/addons/l10n_at_hr_payroll_private/models/mbgm.py +++ b/addons/l10n_at_hr_payroll_private/models/mbgm.py @@ -300,6 +300,47 @@ class L10nAtPayrollMbgm(models.Model): positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV')) 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 (A01–A03/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): for deklaration in self: fehler = [] @@ -310,9 +351,17 @@ class L10nAtPayrollMbgm(models.Model): fehler.append('Beitragskontonummer (BKNR) fehlt.') vstr = (bknr or '')[:2] deklaration.line_ids.unlink() - datensaetze = [] - summe_gesamt = 0 - anzahl = 0 + # AP17: mBGM-Pakete — 'normal' (JAGB 'N') und — bei + # jährlicher Abrechnung GfB (§ 58 Abs 8 ASVG iVm § 6 + # 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': slips = deklaration._l10n_at_slips() nach_an = {} @@ -325,6 +374,13 @@ class L10nAtPayrollMbgm(models.Model): 'Mitarbeiter %s ohne Versicherungsnummer.' % employee.name) 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: fehler.append( 'Mitarbeiter %s mit mehreren Läufen im ' @@ -356,7 +412,8 @@ class L10nAtPayrollMbgm(models.Model): storno_satz.setze('VSNR', version.ssnid) storno_satz.setze('VSUM', abs(original.vsum), cent=True) - datensaetze.append(storno_satz) + paket_gruppen['normal']['saetze'].append( + storno_satz) deklaration.env['l10n.at.payroll.mbgm.line']\ .create({ 'mbgm_id': deklaration.id, @@ -366,43 +423,63 @@ class L10nAtPayrollMbgm(models.Model): 'refw': refw, 'vsum': original.vsum, }) - summe_gesamt += original.vsum - anzahl += 1 + paket_gruppen['normal']['summe'] += original.vsum + paket_gruppen['normal']['anzahl'] += 1 continue bsgr = deklaration._l10n_at_bsgr(version, slip) - tarif = deklaration._l10n_at_tarif(bsgr, version) - if not tarif: + if slip.l10n_at_geringfuegig: + # 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( - 'Mitarbeiter %s: kein Tarifsatz für BSGR ' - '%s (weder TASY-Katalog noch Parameter ' - 'at_mbgm_tarif am Beitragsmonat).' + 'Mitarbeiter %s: fallweise mBGM (G3/G4 mit ' + 'tagesgenauen Tarifblöcken T2/T5) ist ein ' + 'dokumentierter Rest (BSGR %s).' % (employee.name, bsgr)) 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) # Konsistenz gegen die GP4-Payslip-Summen. - ist = (slip._l10n_at_svdn_gesamt() - + slip._l10n_at_svdg_gesamt() - + 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 slip.l10n_at_geringfuegig: + ist = (slip._l10n_at_svdg_uv_geringfuegig() + + slip._at_bv_beitrag()) + else: + ist = (slip._l10n_at_svdn_gesamt() + + slip._l10n_at_svdg_gesamt() + + 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: warnungen.append( 'Mitarbeiter %s: mBGM-VSUM %.2f weicht um ' '%.2f von den GP4-Beitragssummen ab ' '(Tarif-/Rundungsdifferenz prüfen).' % (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({ 'mbgm_id': deklaration.id, 'employee_id': employee.id, @@ -413,46 +490,82 @@ class L10nAtPayrollMbgm(models.Model): deklaration.beitragsmonat.strftime('%Y%m'), employee.id), 'vsum': vsum, + 'jagb': gruppe == 'jagb', }) - datensaetze.extend( + paket_gruppen[gruppe]['saetze'].extend( deklaration._l10n_at_mbgm_datensaetze( employee, version, bsgr, positionen, vsum)) - summe_gesamt += vsum - anzahl += 1 - paket = L10nAtEldaSatz( - L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326, - bezeichnung='mBGM-Paket') - paket.setze('SART', 'PS') - paket.setze('VSTR', vstr) - paket.setze('REFP', 'MBGM-%d-%s' % ( - company.id, - deklaration.beitragsmonat.strftime('%Y%m'))) - paket.setze('BKNR', bknr) - paket.setze('DGNA', company.name[:70]) - paket.setze('JAGB', 'N') - paket.setze('BZRM', deklaration.beitragsmonat.strftime( - '%m%Y')) - paket.setze('GSVZ', '+' if summe_gesamt >= 0 else '-') - paket.setze('GSUM', abs(summe_gesamt), cent=True) - paket.setze('ANZM', anzahl) - datensaetze.insert(0, paket) + paket_gruppen[gruppe]['summe'] += vsum + paket_gruppen[gruppe]['anzahl'] += 1 + datensaetze = [] + for schluessel in ('normal', 'jagb'): + gruppe = paket_gruppen[schluessel] + if not gruppe['anzahl'] and not ( + deklaration.art == 'FZ' + and schluessel == 'normal'): + continue + paket = L10nAtEldaSatz( + L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326, + bezeichnung='mBGM-Paket %s' % schluessel) + paket.setze('SART', 'PS') + paket.setze('VSTR', vstr) + paket.setze('REFP', 'MBGM-%d-%s%s' % ( + company.id, + deklaration.beitragsmonat.strftime('%Y%m'), + '-JAGB' if schluessel == 'jagb' else '')) + 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: deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % ( '\n'.join(fehler), '\n'.join(warnungen) or '—') raise UserError('mBGM-Lauf mit Fehlern abgebrochen:\n' + '\n'.join(fehler)) - bestand = L10nAtEldaBestand( - 'MB', vstr, '02', - hersteller={'name': 'odoo-at-payroll', 'kfz': 'A', - 'plz': company.partner_id.zip, - 'ort': company.partner_id.city, - 'strasse': company.partner_id.street}, - datensaetze=datensaetze) - datei = L10nAtEldaDatei([bestand]) + if datensaetze: + bestand = L10nAtEldaBestand( + 'MB', vstr, '02', + hersteller={'name': 'odoo-at-payroll', 'kfz': 'A', + 'plz': company.partner_id.zip, + 'ort': company.partner_id.city, + 'strasse': company.partner_id.street}, + datensaetze=datensaetze) + datei = L10nAtEldaDatei([bestand]) + deklaration.write({ + 'datei': base64.b64encode(datei.render()), + 'datei_name': 'mbgm_%s.txt' % ( + deklaration.beitragsmonat.strftime('%Y%m'),), + }) deklaration.write({ - 'datei': base64.b64encode(datei.render()), - 'datei_name': 'mbgm_%s.txt' % ( - deklaration.beitragsmonat.strftime('%Y%m'),), 'protokoll': 'WARNUNGEN:\n%s' % ( '\n'.join(warnungen) or '—'), 'state': 'done', @@ -525,6 +638,19 @@ class L10nAtPayrollMbgm(models.Model): class ResCompany(models.Model): _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( string='AT Nachtschwerarbeitsbeitrags-Betrieb (mBGM-Fallback)', help='Betrieb mit Nachtschwerarbeit (ÖGK-Tarifsystem ' @@ -573,6 +699,10 @@ class L10nAtPayrollMbgmLine(models.Model): readonly=True) versicherungsnummer = fields.Char() 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)') vsum = fields.Monetary( string='VSUM', currency_field='currency_id') diff --git a/addons/l10n_at_hr_payroll_private/models/nachtschwerarbeit.py b/addons/l10n_at_hr_payroll_private/models/nachtschwerarbeit.py new file mode 100644 index 0000000..3ae9e29 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/nachtschwerarbeit.py @@ -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 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/payslip_private.py b/addons/l10n_at_hr_payroll_private/models/payslip_private.py index 8b11808..60133c4 100644 --- a/addons/l10n_at_hr_payroll_private/models/payslip_private.py +++ b/addons/l10n_at_hr_payroll_private/models/payslip_private.py @@ -61,6 +61,18 @@ class HrVersionPrivate(models.Model): class HrPayslip(models.Model): _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: # Werktage des KV werden als Arbeitstage aus dem Ressourcenkalender diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py index 45caadb..a88d4b5 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -19,3 +19,5 @@ from . import test_reisekosten_private from . import test_pendler_private from . import test_endabrechnung_private from . import test_karenz_private +from . import test_ap17_private +from . import test_ap18_ap19_private diff --git a/addons/l10n_at_hr_payroll_private/tests/test_ap17_private.py b/addons/l10n_at_hr_payroll_private/tests/test_ap17_private.py new file mode 100644 index 0000000..454c45c --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_ap17_private.py @@ -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)) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/tests/test_ap18_ap19_private.py b/addons/l10n_at_hr_payroll_private/tests/test_ap18_ap19_private.py new file mode 100644 index 0000000..2bb5d05 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_ap18_ap19_private.py @@ -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) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml b/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml index 73d2be5..c663dae 100644 --- a/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml +++ b/addons/l10n_at_hr_payroll_private/views/meldewesen_views.xml @@ -51,6 +51,7 @@ + diff --git a/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml b/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml index 8994bd8..bfa169c 100644 --- a/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml +++ b/addons/l10n_at_hr_payroll_private/views/mitarbeiter_views.xml @@ -34,6 +34,7 @@ +