Compare commits

...

5 Commits

Author SHA1 Message Date
fegger 57522b5be3 [IMP] l10n_at_hr_payroll_private: GP8 Vertragsstichtage-UI, All-in-Deckungsrechnung, DAG-Ausweis, Frist-mBGM-Verlinkung
Vertragsstichtage-UI (views/vertragsfelder_views.xml): GP4-Felder (ALV-Entfall, UV-Entfall, PV-Bonusphase, LNK-frei) + Status (Lehrling, fallweise, Familienabsetzbetrag, ueberw. Nachtarbeit) als gruppierte Payroll-Seite am Mitarbeiterformular (Muster: KV-Einstufung + BV-Gruppe).

All-in-Deckungsrechnung (report/gp8_reports.xml — QWeb auf hr.payslip): GP2-_at_allin_deckung als Ausweis (Pauschale vs. Bezuege, Rest = Über-/Unterdeckung; § 2g AVRAG). DAG-Ausweis (QWeb auf hr.payslip.run): § 1 DAG je Monat — Lohnsumme der Geringfuegigen, Schwelle (1,5 × GFG), Abgabe 19,4 % (GP4 _l10n_at_dag).

Frist-mBGM-Verlinkung (models/fristen_private.py): ref_mbgm_id-Feld am Fristenkalender (Kernmodell kennt mBGM nicht) + action_zum_objekt-Super-Chain; _l10n_at_verlinke_deklarationen setzt Sprungziele fuer offene L16- und mBGM-Fristen.

19.0.8.0.0. Tests: GP8-Kernsuite 6 (100/100 gesamt).
2026-09-12 19:59:03 +02:00
fegger c843701d1a [IMP] l10n_at_hr_payroll: GP8 Fristenkalender, Jahres-Retro, Mitteilungsfile-Import, 2027-Parameter-Rahmen
Fristenkalender (models/fristenkalender.py — l10n.at.payroll.frist): alle lohnverrechnungsrelevanten Melde- und Zahlungsfristen je Company und Jahr (kuratiert: lb-sva-11, lb-mel-08, lb-lvr-04/05): SV/mBGM 15. Folgemonat (Gutschriftprinzip, Respirofrist 3 Tage, Verzugszinsen ab Tag 16), DB/DZ/KommSt 15. Folgemonat, KommSt-Jahreserklärung Ende Maerz, Lohnzettel L16 + Arbeitsstaettenmeldung + §-109a Ende Februar, Schwerarbeitsmeldung Ende Februar (blockiert, ASchV-Datenmodell fehlt). Auto-Erzeugung per ir.cron monatlich am 1. (SV/DB/DZ/KommSt) + jaehrlich am 1. Jaenner (Jahresfristen). Ueberfaellig-Faerbung + Sprungaktion zur GP7-Deklaration (L16; mBGM-Verlinkung im Privatmodul via fristen_private.py).

Jahres-Retro-/Aufrollungslauf (models/jahresretro.py — Wizard l10n.at.payroll.jahresretro.wizard): Kontrollsechstel-Verpflichtungslauf § 67 Abs 10 EStG (Pruefung je AN auf SZ-Ueberhang und Guenstig-Vergleich Fixsaetze vs. Tarif; Handlungsempfehlung fuer die Odoo-Korrektur-Pipeline: Refund-Payslips + Neuberechnung). Optional §-77-Abs-3/4-Aufrollung (15.-Jaenner-Grenze; Krankengeld-Blocker). SV-Aufrollung rollt auf den Beitragsmonat (GP4-Beitragsmonat); mBGM-Storno als Folgeaktion (GP7 art=ST).

Mitteilungsfile-Import (models/mitteilungsfile.py — Wizard): ELDA-Rueckantwort-XML (mitteilung_{Protokollnummer}.xml) parsen, Fehler/Warnungen lesbar aufbereiten; XSDs lokal beschafft (.firecrawl/ris/elda/). Dateibasiert (Praemisse P7): Upload durch den Mandanten.

2027-Parameter-Rahmen (data/rule_parameters_2027.xml): NUR Struktur (WF-Vektor-Default + AV-Staffel tiers=[] da § 2a AMPFG aufgehoben BGBl I 62/2026). KEINE Werte aus Trainingswissen — der Wartungszyklus beschafft die offiziellen 2027-Werte.

B14-laengster-Urlaubs-Teil: verifiziert — aktuelle Implementierung ist praktisch korrekt (erster Urlaubsmonat, _at_sz_bereits_bezahlt gegen Doppelauszahlung); mehrmonatig gesplittete Urlaube sind ein Randfall mit vorwaertsblickenden Planungsdaten, dokumentiert ohne Code-Fix.

Tests: test_gp8 (6 — Frist-Definitionen, Cron-Idempotenz, Ueberfaellig-Logik, Mitteilungsfile-Parser, Import-ohne-Datei-UserError, Jahresretro-ohne-Daten). 100/100 gruen (Wegwerf-DB). 19.0.5.0.0.
2026-09-12 19:58:53 +02:00
fegger ea14c48cca [IMP] odoo-at-payroll: GP7-Bau, DM-ORG-42.7.0-Verifikationen und Nutzer-Entscheidungen 5.1-5.4 dokumentiert
CHANGELOG-GP7-Eintrag; Plan-Abschnitt-10-Update (ELDA-Fixsatz-Konventionen, KZ-210-§-25-Gesamtbrutto-Fund, mBGM-Abschläge je Basis, Nutzer-Entscheidungen: Arbeitsstätte in GP7, Mitteilungsfile-Import GP8, FB+ nicht im Lohnsteuerkern/ANV + Kind-Modell für FABO+-Wartung); MEMORY-Handoff (94/94, Wegwerf-DB, GP8 als nächster Bauabschnitt, offene Reste: odoo_dev-Update, geringfügig-mBGM-Parametrisierung, CRLF/ELDA-Kanal-Verifikation, Referenzbuchhaltungs-Endfixierung, Schwerarbeit nach ASchV-Datenmodell-Bau).
2026-09-12 17:31:21 +02:00
fegger 14940ac31a [IMP] l10n_at_hr_payroll_private: GP7 mBGM/JASV + Versichertenmeldung + fallweise Anmeldung auf dem ELDA-Framework
mBGM/JASV (models/mbgm.py — DM-ORG 42.7.0 E.32, BEST MB, Version 02): Paket PS (BKNR/DGNA/BZRM/GSUM/ANZM) → mBGM G1 (REFW/VSNR/FANA/VONA/VSUM/VERG=1) → Tarifblock T1 (BSGR/VVON) → Verrechnungsbasis BS (VBTY/VBBT) → Verrechnungsposition V1 (VPTY/VPVZ/VPTA/RSVZ/RSUM). KEIN DN/DG-Zweig-Split: T01 = GESAMT-Prozentsatz des Tarifsystems je Beschäftigtengruppe (Parameter at_mbgm_tarif, normativ: laufend 39,05 % = KV 7,65 + PV 22,80 + AV 5,90 + UV 1,10 + WF 1,00 + IESG 0,10; SZ 37,55 % = DN KV 3,87 + PV 10,25 + AV 2,95 + DG 20,48). Abschläge als EIGENE Verrechnungspositionen je Basis: A03/A02/A01 = Minderung AV auf 0/1/2 % (§ 2a AMPFG, nur DN-AV, je Verrechnungsbasis AB UND SZ), A09 = UV-Entfall 60, A10 = AV+IE-Entfall Pensionsanspruch (Konkurrenz a4: sperrt Staffelabschlag), A15 = PV-Halbierung Bonusphase — konsistent mit der GP4-§-2a-DN-seitig-Konvention. BV (BMSVG): eigene Verrechnungsbasis BV (ungedeckelt, GP5) + Position V01 1,53 %. RSUM = Basis x Satz (mBGM-Tarifierung), Konsistenz-Check gegen GP4-Payslip-Beitragssummen ±0,10 € als Prüfwarnung. Storno: R1-Sätze mit REFU/VSUM wie Original. Fehlanzeige: Paket ohne mBGM-Sätze (ANZM 0). Geringfügige/fallweise ohne Parametrisierung: listenweiser Abbruch (keine Tarif-Erfindung; BSGR B044/B045/B010/B030 aus dem SV-Tarifsystem pflegen).

Versichertenmeldung (models/versichertenmeldung.py — DM-ORG E.29, BEST VR, Version 03, Satzlänge 772): M3 vor Arbeitsantritt / M4 (7 Kalendertage ab PV-Ende) mit deklarativer Event-Erkennung über die Meldungs-Registry (l10n.at.payroll.vr.event) — idempotente Läufe, Dimona-auto-Idee ohne write-diff-Hacks. Felder: BKNR/VSNR/GEBD/FANA/VONA (Namenssplit am letzten Leerzeichen), ADAT/EBSV, BBER 01-04 aus l10n_at_personalgruppe/lehrling, GERF (Kataloggehalt vs. GFG), FRDV, BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ (Stunden x100, neu ab 01.01.2026), AGRD aus dem hr.departure.reason-Mapping (Standard 00). Fallweise Anmeldung (BEST MA, SART 32, Version 07, Länge 450): MVP je Anmeldetag am Vertragsbeginn; tagesgenaue Einsatztage via Work-Entry-Auswertung = dokumentierter Rest.

NSchAB-Branche als Company-Flag (l10n_at_nschab): B001-T01 + 3,80 % (Schlechtwetterentschädigung, ÖGK-Tarifsystem Normalbetriebe mit Schlechtwetterentschädigung).

Tests: test_meldewesen_private (2 — mBGM-Satzhierarchie mit A03 je Basis aus November-Fixture + GSUM-Konsistenz; VR M3/M4 mit Registry-Idempotenz und AGRD). Rechenfälle docs/rechenfaelle/meldewesen.md. 19.0.7.0.0.
2026-09-12 17:31:12 +02:00
fegger c474a39466 [IMP] l10n_at_hr_payroll: GP7 ELDA-Fixsatz-Framework, L16 + Arbeitsstättenmeldung, Kind-Modell, Belege (AP5/AP6)
ELDA-Fixsatz-Framework (models/elda.py) nach DM-ORG 42.7.0 Kapitel C.1/C.2+E.1-E.3: Fixlängen-Sätze ohne Feldtrennzeichen (a/a-n linksbündig, Grundstellung blank; n rechtsbündig, Grundstellung 0, führende Nullen, KEINE Interpunktion — Beträge EURO-Cent kaufmännisch gerundet, Prozentsätze x1000 mit 3 Nachkommastellen), Encoding ISO-8859-15 (Standard-Encoding für Fixlängen), optionaler Dateiheader (DHKZ 2x blank + DHVN 01 + ENCD), Vorlaufsatz SART 00 (BEST/VSTR/VERS) + Schlusssatz SART 99 mit SANZ; SANR lückenlos ab 1 je Datenbestand; CRLF als Satztrenner (Praxis-Annahme, DM-ORG normiert keines — Parallellauf verifizieren).

Lohnzettel Finanz L16 (models/lohnzettel_l16.py): Informationssatz I1 (SART I1, Länge 1.100, Strukturversion 03) + Mitteilungssatz L1 (SART L1, Länge 3.500, Lohnzettelversion 28 — zwingend ab 01.03.2026) mit vollständiger, PDF-verifizierter Feldtabelle (~210 Felder inkl. Kinderblock 15x123 ab Pos. 1501; DM-ORG 42.7.0 E.13/E.14). KZ-245-Formel: KZ 210 = Bruttobezüge gem. § 25 GESAMT (DM-ORG-Feldbeschreibung) — die Formel 210-215-220-230-243 ergibt die laufende Steuerbemessung, per Konstruktion konsistent mit der GP3-§-66-Kette (Ableitung aus l10n_at_bemessung/steuerbefreiung_68/s67_abs und Regelcodes; bewusst KEIN paralleles KZ-Klassifikationsfeld). Arbeitsstättenmeldung § 34 Abs 6 ASVG: SART 45 (Länge 300, Version 02, BEST AD/VSTR ST Statistik Austria) je AN am 31.12. aus dem Dienstort-Partner (GKZ manuell, Warnung wenn fehlt) — im selben Jahreslauf (Nutzer-Entscheidung 5.1).

Kind-Datenmodell (models/kind.py — l10n.at.payroll.kind): Stammdaten-Wartung des Familienbonus Plus (Nutzer-Entscheidung 5.3: FB+ rechnet NICHT im Lohnsteuerkern — bei Unterschreiten der Steuer auf 0 erfolgt die Geltendmachung über die Arbeitnehmerveranlagung); Felder für den L16-Kinderblock (Familienname/Vorname/Geburtsdatum/VSNR/Staatsangehörigkeit/FB+-Monate voll/halb, Alterstaffel 166,68/58,34).

Stammdaten (models/meldewesen.py): Beitragskontonummer BKNR + Lohnsteuernummer (res.company), Gemeindekennziffer GKZ (res.partner), VSNR-Formatprüfung LLLPTTMMJJ auf hr.version.ssnid (Prüfzifferalgorithmus = dokumentierter Rest, SVS-Quelle beschaffen), eSV-Abmeldegrund AGRD-Mapping auf hr.departure.reason (Standard 00); fallweise-Beschäftigungs-Flag auf hr.version (§ 33 Abs 1a Z 1 ASVG).

Belege (AP6): Lohnzettel AT (ir.actions.report auf hr.payslip, AT-Layout mit Bezügen/DN-Abzügen/DG-Anteilen/Netto), Monatsdienstliste (mBGM-Kontrollschicht je AN) + Monatsjournal (je Lohnart Summen je Lauf) auf hr.payslip.run als QWeb (models/monatsreports.py + report/belege_reports.xml; account.report wird in keinem HR-Modul genutzt).

Tests: test_elda_l16 (4 — Feldformatierung/Cent-Beträge/skala, Bestands-Assembly SANR/SANZ/CRLF/ISO-8859-15/Dateiheader, L16-Lauf mit positionsexakten Assertions aus der November-Fixture, Arbeitsstätten-Satz). 19.0.4.0.0.
2026-09-12 17:30:59 +02:00
35 changed files with 4558 additions and 25 deletions
+67 -7
View File
@@ -2,8 +2,45 @@
## Current focus
**GP6 FI-Buchung UGB umgesetzt und grün (88/88 Tests) — nächster
Bauabschnitt GP7 Meldewesen/Belege (Kern-Bau AP5/AP6)** (2026-09-12):
**GP7 Meldewesen/Belege umgesetzt und grün (94/94 Tests) — nächster
Bauabschnitt GP8 Jahreswechsel/UI/Fristen** (2026-09-12): Kern
19.0.4.0.0 — ELDA-Fixsatz-Framework (`models/elda.py`: Fixlängen,
a/n blank/n null, Cent kaufmännisch, Prozente ×1000, ISO-8859-15,
Dateiheader, Vorlauf SART 00/Schluss SART 99, SANR ab 1 lückenlos,
SANZ inkl. Rahmen; **CRLF-Satztrenner = Praxis-Annahme, DM-ORG
normiert keines — Parallellauf verifizieren**); L16 (`models/
lohnzettel_l16.py`: I1 1.100 + L1 **3.500**, Lohnzettelversion 28,
BEST LF/VSTR 94, ~210 Felder, Kinderblock 15×123 ab Pos. 1501;
**KZ 210 = § 25 GESAMTBrutto** — KZ-245-Formel = laufende Steuer-
bemessung, per Konstruktion GP3-konsistent, Ableitung aus Kern-Flags
ohne paralleles KZ-Feld) + Arbeitsstättenmeldung § 34 Abs 6 (SART 45,
BEST AD/VSTR ST, je AN am 31. 12., GKZ manuell am Dienstort-Partner);
Kind-Modell `l10n.at.payroll.kind` (FB+-Wartung; Nutzer: **FB+ rechnet
NICHT im Lohnsteuerkern** — Steuer < 0 → ANV); Stammdaten
(BKNR/Lohnsteuernummer Company, GKZ Partner, VSNR-Formatprüfung
hr.version.ssnid — Prüfziffer Rest, eSV-AGRD-Mapping
hr.departure.reason); Belege: Lohnzettel AT + Monatsdienstliste +
Monatsjournal (QWeb auf hr.payslip.run; account.report in keinem
HR-Modul). Privat 19.0.7.0.0 — mBGM/JASV (`models/mbgm.py`: PS/G1/
T1/BS/V1, T01/T02-Gesamtsatz aus `at_mbgm_tarif` **normativ laufend
39,05 %/SZ 37,55 %**; Abschläge **je Basis AB und SZ**: A03/A02/A01
§-2a-DN-AV, A09 UV-60, A10 AV+IE, A15 PV; BV-Basis GP5 + V01;
RSUM=Basis×Satz mit GP4-Konsistenzcheck ±0,10 €; Storno R1 REFU,
Fehlanzeige Null-Paket; geringfügig/fallweise OHNE Parametrisierung →
Abbruch, keine Tariferfindung) + Versichertenmeldung (`models/
versichertenmeldung.py`: M3/M4 deklarative Event-Erkennung über
Registry `l10n.at.payroll.vr.event` — idempotent, KEIN write-diff;
BBER 0104, GERF Gehalt-vs-GFG, BVAB/BVJN aus GP5, VWAZ h×100, AGRD;
fallweise BEST MA/SART 32 MVP je Anmeldetag; NSchAB-Company-Flag
+3,80 B001). Tests 94/94 (test_elda_l16 4 + test_meldewesen_private
2), Wegwerf-DB frisch; Rechenfälle `docs/rechenfaelle/meldewesen.md`.
Out: Schwerarbeit (DM-ORG E.22-Struktur vorhanden, keine ASchV-
Basis), § 109a, L17, Mitteilungsfile-Import (5.2→GP8), FB+-LSt
(ANV), geringfügig-mBGM bis Param. Offen: odoo_dev-Update (GP4-
Stand!), Referenzbuchhaltungs-Endfixierung, Parallellauf-Verifikation
CRLF/Kanäle.
**GP6 FI-Buchung UGB umgesetzt und grün (88/88 Tests)** (2026-09-12):
privat 19.0.6.0.0 — Buchungsmatrix nach **KFS/BW 6** (Nutzer-
Referenz-Exemplar `.additional_docs/KFSBW6_13072017_RF.pdf`, extrah.
`/tmp/kfsbw6/` ad hoc): Hook `_configure_payroll_account_at`
@@ -192,6 +229,24 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“).
## Completed
- **GP7 Meldewesen/Belege umgesetzt (94/94 grün)** (2026-09-12):
Kern 19.0.4.0.0: `models/elda.py` (Fixsatz-Framework),
`lohnzettel_l16.py` (L16 I1/L1 + Arbeitsstätte SART 45;
`l10n.at.payroll.l16` + `.line`), `kind.py` (`l10n.at.payroll.kind`),
`meldewesen.py` (BKNR/Lohnsteuernummer/GKZ/VSNR-Prüfung/AGRD),
`monatsreports.py` + `report/belege_reports.xml` (3 Belege),
`views/meldewesen_views.xml`. Privat 19.0.7.0.0: `mbgm.py`
(`l10n.at.payroll.mbgm[.line]`), `versichertenmeldung.py`
(`l10n.at.payroll.vr` + `.event`-Registry),
`data/rule_parameters_mbgm_private.xml` (at_mbgm_tarif B001/B002
39,05/37,55 ab 2026), `views/meldewesen_views.xml`, NSchAB-Flag.
DM-ORG-42.7.0-Satzstrukturen PDF-verifiziert extrahiert (mBGM
E.32, L16 E.13/E.14 mit vollständiger L1-Tabelle inkl. Kinderblock-
Offsets, VR E.29 mit Pflichtmatrix/AGRD/BBER, Arbeitsstätte E.20,
fallweise E.18; TXT-Fehler vs. PDF dokumentiert — PDF gewinnt).
Rechenfälle `docs/rechenfaelle/meldewesen.md`; README/CHANGELOG/
PLAN-10/MEMORY nachgezogen.
- **GP6 FI-Buchung UGB umgesetzt (88/88 grün)** (2026-09-12):
`l10n_at_hr_payroll_private` 19.0.6.0.0 — Buchungsmatrix KFS/BW 6:
`models/account_chart_private.py` (Matrix-Dict + Hook
@@ -1207,12 +1262,17 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“).
GP5 Abfertigung Neu; **GP6 FI-Buchung UGB umgesetzt 2026-09-12**
`test_kv_katalog` 8 + `test_entgelt_private` 21 + `test_lst_private`
6 + `test_sv_private` 8 + `test_bv_private` 10 + `test_account_private`
4 grün auf Wegwerf-DB (**88/88**, frischer `-i`-Lauf — `odoo_dev`
noch auf GP4-Stand, Update nachholen); Rechenfall-Fixtures unter
`docs/rechenfaelle/` inkl. buchhaltung.md (KFS/BW-6-Matrix);
GP7 Meldewesen/Belege als Nächstes)
4 + `test_meldewesen_private` 2 grün auf Wegwerf-DB (**94/94**
gesamt mit Kern; `odoo_dev` noch auf GP4-Stand, Update nachholen);
Rechenfall-Fixtures unter `docs/rechenfaelle/` inkl. buchhaltung.md
+ meldewesen.md;
GP7 umgesetzt: mBGM/Versichertenmeldung/fallweise auf dem Kern-Framework;
GP8 Jahreswechsel/UI/Fristen als Nächstes)
- `addons/l10n_at_hr_payroll/` — Bundes-Kern (SV/TASY + GP3 Lohnsteuer
`models/lohnsteuer.py` + **GP4 SV/DB `models/sozialversicherung.py`**
+ GP4 SV/DB + **GP7 ELDA/L16/Arbeitsstätte/Belege** 19.0.4.0.0 —
`models/elda.py`, `lohnzettel_l16.py`, `kind.py`, `meldewesen.py`,
`monatsreports.py`, `report/belege_reports.xml`;
`test_elda_l16` 4)
(DG/Geringfügigkeit/§ 49/Altersstichtage/§ 2a-Staffel/WF-Vektor/
Beitragsmonat/DAG), `rule_parameters_lst_2026.xml` +
`rule_parameters_sv_2026.xml`; **74/74 grün**)
+31 -2
View File
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)',
'version': '19.0.3.1.0',
'version': '19.0.5.0.0',
'author': 'ixsol',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -11,7 +11,7 @@
Sektor-neutraler Bundes-Kern der österreichischen Personalverrechnung
auf der Odoo-19 ``hr_payroll``-Engine. Wird konsumiert von
``l10n_at_gemeinde_payroll`` (Bgld. GemBG) und später vom General-AT-
``l10n_at_gemeinde_payroll`` (Bgld. GemBG) und vom General-AT-
Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/``
(PLAN-payroll-bgld.md, RECHTSQUELLEN-Bgld.md, PLAN-repo-split.md).
@@ -32,8 +32,33 @@ Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/``
``rule_parameters_sv_2026.xml``; BV-Beitrag Abfertigung Neu
(BMSVG § 6 Abs 1, ``at_sv_bv_dg``) als Parameter für das
General-AT-Modul (GP5);
- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework
(``models/elda.py`` — Vorlauf/Schluss, SANR/SANZ, Cent-Formatter,
ISO-8859-15), **Lohnzettel Finanz L16 + Arbeitsstättenmeldung**
(``models/lohnzettel_l16.py`` — I1/L1 nach DM-ORG 42.7.0,
Lohnzettelversion 28, KZ-245-Formel-Prüfung, BEST LF/94 bzw. AD/ST),
Kind-Datenmodell für die FB+-Wartung (``models/kind.py``),
Meldewesen-Stammdaten (BKNR/Lohnsteuernummer, GKZ am Partner,
VSNR-Formatprüfung, eSV-Abmeldegrund-Mapping,
``models/meldewesen.py``), Belege Lohnzettel AT/Monatsdienstliste/
Monatsjournal (QWeb, ``models/monatsreports.py``);
- **GP8:** Fristenkalender (``models/fristenkalender.py`` — Cron,
Auto-Erzeugung, Sprung zur GP7-Deklaration), Jahres-Retro-/
Aufrollungslauf (``models/jahresretro.py`` — Kontrollsechstel
§ 67 Abs 10, §-77-Abs-3/4-Option), Mitteilungsfile-Import
(``models/mitteilungsfile.py`` — ELDA-Rückantwort-XML),
2027-Parameter-Struktur (nur Rahmen);
- deklarative TASY-Mapping-Tabelle und Import-Wizard (Preview,
Sprungwarnung > 10 %, explizite Bestätigung, versionierte Werte);
- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework
(``models/elda.py``), Lohnzettel Finanz L16 + Arbeitsstättenmeldung
(``models/lohnzettel_l16.py``, DM-ORG 42.7.0, PDF-verifizierte
Satzstrukturen), Kind-Datenmodell für die FB+-Wartung
(``models/kind.py`` — Nutzer-Entscheidung: FB+ rechnet NICHT im
Lohnsteuerkern, Arbeitnehmerveranlagung), Meldewesen-Stammdaten
(BKNR/Lohnsteuernummer, GKZ am Partner, VSNR-Formatprüfung, eSV-
Abmeldegrund-Mapping), Belege Lohnzettel AT/Monatsdienstliste/
Monatsjournal als QWeb (``report/belege_reports.xml``);
- folgen nach Freigabe der Bgld.-APs: Meldewesen (L16/FinanzOnline,
eSV/ELDA, AP5), Belege (AP6).
@@ -46,8 +71,12 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft
'data/rule_parameters_2026.xml',
'data/rule_parameters_lst_2026.xml',
'data/rule_parameters_sv_2026.xml',
'data/rule_parameters_2027.xml',
'data/tasy_mapping.xml',
'views/tasy_views.xml',
'views/meldewesen_views.xml',
'views/fristen_views.xml',
'report/belege_reports.xml',
],
'countries': ['at'],
'license': 'LGPL-3',
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- GP8: 2027-Parameter-Platzhalter (NUR Struktur, keine Werte!). -->
<!-- Der Wartungszyklus (RUNBOOK) beschafft die offiziellen 2027- -->
<!-- Werte (SV-Werte PDF, Lohnsteuer-Tarif, KV-Gehaltstabellen) -->
<!-- und pflegt diese Zeilen nach — GP8 liefert nur den Rahmen. -->
<!-- KEINE Werte aus Trainingswissen! -->
<record id="hr_rule_parameter_value_at_sv_wf_vektor_2027" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_wf_vektor"/>
<field name="date_from">2027-01-01</field>
<field name="parameter_value">{'default': {'dn': 0.5, 'dg': 0.5}}</field>
</record>
<!-- AV-Staffel 2027: § 2a AMPFG aufgehoben (BGBl I 62/2026) → -->
<!-- tiers=[] (voller DN-Satz) — das ist bereits 2026 verifiziert -->
<!-- und kein Trainingswert. -->
<record id="hr_rule_parameter_value_at_sv_av_staffel_2027" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_av_staffel_ampfg"/>
<field name="date_from">2027-01-01</field>
<field name="parameter_value">{'tiers': [], 'dn_voll': 2.95}</field>
</record>
</odoo>
+7 -1
View File
@@ -2,4 +2,10 @@
from . import tasy
from . import lohnsteuer
from . import sozialversicherung
from . import sozialversicherung
from . import elda
from . import meldewesen
from . import kind
from . import lohnzettel_l16
from . import fristenkalender
from . import jahresretro
from . import mitteilungsfile
+256
View File
@@ -0,0 +1,256 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""ELDA-Fixsatz-Framework (GP7/AP5) — „Datenaustausch mit Dienstgebern"
(DM-ORG 42.7.0, 07/2026; lokal `.firecrawl/ris/elda/`, unversioniert).
Dateikonventionen (DM-ORG Kapitel C.1/C.2, E.1E.3 — verifiziert 2026-09-12):
- Datei = optionaler Dateiheader (DHKZ 2×blank + DHVN 01 + ENCD) + mindestens
ein Datenbestand; Datenbestand = Vorlaufsatz (SART 00, Feld VERS = Version
der nachfolgenden Satzstrukturen) + Datensätze + Schlusssatz (SART 99,
SANZ = Satzanzahl inkl. Vorlauf und Schluss).
- Fixlängen-Sätze ohne Feldtrennzeichen; alle Sätze eines Bestands nehmen
die maximale Satzlänge des Bestands an (RESE-Auffüllung). Sätze werden
zeilenweise mit CRLF getrennt (die DM-ORG normiert KEIN explizites
Satztrennzeichen — CRLF ist die Praxis der ELDA-Kanäle; Annahme im
Parallellauf gegen den Mandanten-ELDA-Zugang verifizieren).
- ``a``/``a/n``: linksbündig, Grundstellung blank; ``n``: rechtsbündig,
Grundstellung 0, führende Nullen, KEINE Interpunktion (Beträge als
EURO-Cent, kaufmännisch gerundet; Prozentsätze mit impliziten
Nachkommastellen via ``skala``).
- Encoding Fixlängen-Dateien: ISO-8859-15 (Standard-Encoding; der
Dateiheader deklariert ENCD). Vorzeichen gehören in eigene V-Felder
(RSVZ/Vxxx), ``n``-Felder sind nie negativ.
"""
from datetime import date, datetime
from decimal import ROUND_HALF_UP, Decimal
L10N_AT_ELDA_ENCODING = 'ISO-8859-15'
L10N_AT_ELDA_ZEILENTRENNER = '\r\n'
# Identifikationsteil (E.1, S. 174) — 20 Stellen, alle Satzarten gleich;
# alle Angaben zwingend.
L10N_AT_ELDA_IDTEIL = [
(1, 'SART', 1, 2, 'a'), # Satzart
(2, 'SANR', 3, 7, 'n'), # laufende Satznummer je Bestand, ab 1
(3, 'UVST', 10, 2, 'a'), # datenübernehmender Träger (ED = ELDA)
(4, 'OBUS', 12, 7, 'n'), # Ordnungsbegriff der übermittelnden Stelle
(5, 'VSTR', 19, 2, 'a'), # zuständiger Träger (D.4)
]
# Vorlaufsatz-Datenteil (E.2, S. 175177, Version 08) — folgt dem IDTEIL;
# zwingend außer HTEL/SOID/VNMF; Satzlänge = maximale Satzlänge des Bestands.
L10N_AT_ELDA_VORLAUF = [
(2, 'PROJ', 21, 2, 'a'), # DM; TM = Testdaten
(3, 'BEST', 23, 2, 'a'), # Verarbeitung (MB/VR/LF/AD/MA …, E.2)
(4, 'DTNR', 25, 6, 'a'), # Datenträgernummer (TP-Übermittlung: frei)
(5, 'EDAT', 31, 8, 'n'), # TTMMJJJJ
(6, 'EZEI', 39, 6, 'n'), # HHMMSS
(7, 'HRST', 45, 45, 'a'), # Herstellername
(8, 'HKFZ', 90, 3, 'a'), # Internationales Kfz-Kennzeichen (A)
(9, 'HPLZ', 93, 7, 'a'),
(10, 'HORT', 100, 20, 'a'),
(11, 'HSTR', 120, 30, 'a'),
(12, 'VERS', 150, 2, 'n'), # Version der Satzstrukturen (D.26)
(13, 'HTEL', 152, 20, 'a'),
(14, 'SOID', 172, 70, 'a'),
(15, 'VNMF', 242, 5, 'a'), # Mitteilungsfile-Version
]
# Schlusssatz-Datenteil (E.3, S. 178, Version 01) — nach IDTEIL;
# SANZ zwingend, ELNR/HEMA optional.
L10N_AT_ELDA_SCHLUSS = [
(2, 'SANZ', 21, 6, 'n'), # Satzanzahl inkl. Vorlauf- und Schlusssatz
(3, 'ELNR', 27, 6, 'n'),
(4, 'HEMA', 33, 60, 'a'),
]
def l10n_at_euro_cent(betrag):
"""EURO-Betrag → Cent (kaufmännisch gerundet; DM-ORG: keine
Interpunktion)."""
return int((Decimal(str(betrag)) * 100).quantize(
Decimal('1'), rounding=ROUND_HALF_UP))
class L10nAtEldaSatz:
"""Ein Fixlängen-Satz: Felddaten (Nr, Name, Pos, Länge, Typ), zwingende
Grundstellung je Typ, Rendering auf die Bestands-Satzlänge. Kein ORM —
reine Bau-Hilfe der Meldungsgeneratoren (DmfA-Muster: Datensatz-Baum im
Python-Teil, Validierung/Prüfprotokoll darüber)."""
def __init__(self, struktur, satzlaenge, bezeichnung=''):
self.struktur = list(struktur)
self.satzlaenge = satzlaenge
self.bezeichnung = bezeichnung
self._felder = {f[1]: f for f in self.struktur}
self._werte = {}
if self.struktur:
letzte = max(f[2] + f[3] - 1 for f in self.struktur)
if letzte > satzlaenge:
raise ValueError(
'Struktur %s endet bei Position %d — Satzlänge %d '
'überschritten' % (bezeichnung, letzte, satzlaenge))
def setze(self, name, wert, cent=False, skala=0):
"""Feld setzen; ``wert`` None übernimmt die Grundstellung.
``cent``: EURO-Betrag → Cent; ``skala``: implizite Nachkommastellen
(z. B. Prozentsatz mit 3 Nachkommastellen → skala=3)."""
if wert is None:
return self
nr, fname, pos, laenge, typ = self._felder[name]
if cent:
wert = l10n_at_euro_cent(wert)
if isinstance(wert, float):
wert = int((Decimal(str(wert)) * (10 ** skala)).quantize(
Decimal('1'), rounding=ROUND_HALF_UP))
elif isinstance(wert, int):
wert = wert * (10 ** skala)
roh = str(wert)
if typ == 'n':
if not roh.isdigit():
raise ValueError('Feld %s (%s): %r ist nicht numerisch' % (
name, self.bezeichnung, roh))
roh = roh.rjust(laenge, '0')
else:
roh = roh.ljust(laenge, ' ')
if len(roh) > laenge:
raise ValueError('Feld %s (%s): %r länger als %d' % (
name, self.bezeichnung, roh, laenge))
self._werte[name] = roh
return self
def setze_datum(self, name, wert, fmt='TTMMJJJJ'):
"""Datum in DM-ORG-Feldformat (Vorlaufsatz EDAT = TTMMJJJJ,
L16-Übermittlungsfelder = JJJJMMTT, LZP/AGBD = TTMMJJ)."""
if wert is None:
return self
if isinstance(wert, str):
wert = datetime.strptime(wert, '%Y-%m-%d').date()
formate = {'TTMMJJJJ': '%d%m%Y', 'JJJJMMTT': '%Y%m%d',
'TTMMJJ': '%d%m%y'}
if fmt not in formate:
raise ValueError('Unbekanntes Datumsformat %s' % fmt)
return self.setze(name, wert.strftime(formate[fmt]))
def setze_zeit(self, name, wert):
if wert is None:
return self
return self.setze(name, wert.strftime('%H%M%S'))
def ist_gesetzt(self, name):
return name in self._werte
def wert(self, name):
return self._werte.get(name)
def render(self):
"""Satz in Bestands-Satzlänge; unbelegte Felder in Grundstellung
(``a`` blank / ``n`` 0), Positionen nach dem letzten Feld blank."""
puffer = [' '] * self.satzlaenge
for nr, name, pos, laenge, typ in self.struktur:
roh = self._werte.get(name)
if roh is None:
roh = '0' * laenge if typ == 'n' else ' ' * laenge
puffer[pos - 1:pos - 1 + laenge] = roh
return ''.join(puffer)
class L10nAtEldaBestand:
"""Datenbestand: Vorlaufsatz + Datensätze + Schlusssatz zu EINER
Verarbeitung (BEST) für EINEN zuständigen Träger (VSTR). SANR zählt
lückenlos ab 1, SANZ inklusive Vorlauf/Schluss (Prüfvorschrift C.1.2);
alle Sätze nehmen die maximale Satzlänge der Datensätze an."""
def __init__(self, best, vstr, vers, hersteller=None, test=False,
obus='0000000', datensaetze=None):
self.best = best
self.vstr = vstr
self.vers = vers
self.hersteller = hersteller or {}
self.projekt = 'TM' if test else 'DM'
self.obus = obus
self.datensaetze = datensaetze or []
def ergaenze(self, satz):
self.datensaetze.append(satz)
return satz
def render(self, erstellungsdatum=None):
"""Zeilen des Bestands (ohne Zeilentrenner)."""
if not self.datensaetze:
raise ValueError('Datenbestand %s ohne Datensätze' % self.best)
heute = erstellungsdatum or date.today()
satzlaenge = max(s.satzlaenge for s in self.datensaetze)
# Der Vorlaufsatz nimmt die Satzlänge des Bestands an — aber
# nie kürzer als seine eigene Struktur (DM-ORG E.2: Vorlauf
# trägt alle Herstellfelder; reale Bestände sind ≥ 246).
satzlaenge = max(
satzlaenge,
max(f[2] + f[3] - 1 for f in
(L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF)))
zeilen = []
vorlauf_struktur = [
f for f in (L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_VORLAUF)
if f[2] + f[3] - 1 <= satzlaenge]
vorlauf = L10nAtEldaSatz(
vorlauf_struktur, satzlaenge,
bezeichnung='Vorlaufsatz %s' % self.best)
vorlauf.setze('SART', '00')
vorlauf.setze('SANR', 1)
vorlauf.setze('UVST', 'ED')
vorlauf.setze('OBUS', self.obus)
vorlauf.setze('VSTR', self.vstr)
vorlauf.setze('PROJ', self.projekt)
vorlauf.setze('BEST', self.best)
vorlauf.setze_datum('EDAT', heute, 'TTMMJJJJ')
vorlauf.setze_zeit('EZEI', datetime.now())
vorlauf.setze('HRST', (self.hersteller.get('name') or '')[:45])
vorlauf.setze('HKFZ', self.hersteller.get('kfz', 'A'))
vorlauf.setze('HPLZ', self.hersteller.get('plz'))
vorlauf.setze('HORT', self.hersteller.get('ort'))
vorlauf.setze('HSTR', self.hersteller.get('strasse'))
vorlauf.setze('VERS', self.vers)
vorlauf.setze('VNMF', '3.0')
zeilen.append(vorlauf.render())
for nummer, satz in enumerate(self.datensaetze, start=2):
# Datensatz-Generatoren setzen ihre SART selbst; der Bestand
# ergänzt Trägerangaben (falls offen) und die laufende
# Satznummer (C.1.2: lückenlos ab 1).
if not satz.ist_gesetzt('UVST'):
satz.setze('UVST', 'ED')
if not satz.ist_gesetzt('OBUS'):
satz.setze('OBUS', self.obus)
if not satz.ist_gesetzt('VSTR'):
satz.setze('VSTR', self.vstr)
satz._werte['SANR'] = str(nummer).rjust(7, '0')
zeilen.append(satz.render())
schluss = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L10N_AT_ELDA_SCHLUSS, satzlaenge,
bezeichnung='Schlusssatz %s' % self.best)
schluss.setze('SART', '99')
schluss.setze('SANR', len(self.datensaetze) + 2)
schluss.setze('UVST', 'ED')
schluss.setze('OBUS', self.obus)
schluss.setze('VSTR', self.vstr)
schluss.setze('SANZ', len(self.datensaetze) + 2)
zeilen.append(schluss.render())
return zeilen
class L10nAtEldaDatei:
"""ELDA-Datei: Dateiheader (ENCD-Standard ISO-8859-15) + Datenbestände;
Rendering in Bytes (Encoding + CRLF-Zeilentrenner, letzte Zeile mit
Abschluss-Trenner)."""
def __init__(self, bestaende=None, header=True):
self.bestaende = bestaende or []
self.header = header
def render(self):
zeilen = []
if self.header:
zeilen.append(' 01' + L10N_AT_ELDA_ENCODING.ljust(50))
for bestand in self.bestaende:
zeilen.extend(bestand.render())
roh = L10N_AT_ELDA_ZEILENTRENNER.join(zeilen) \
+ L10N_AT_ELDA_ZEILENTRENNER
return roh.encode(L10N_AT_ELDA_ENCODING)
@@ -0,0 +1,230 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Fristenkalender (GP8/AP8): alle lohnverrechnungsrelevanten
Melde-/Zahlungsfristen je Company und Jahr.
Kuratierte Fristen (lb-sva-11, lb-mel-08, lb-lvr-04/05, Stand
2026-06…08):
- SV-Beiträge (mBGM): Einlangen **15. des Folgemonats** (Gutschrift-
prinzip; Respirofrist 3 Tage; Verzugszinsen ab dem 16. Tag);
Vorschreibebetriebe 7.
- DB (FLAF) / DZ / KommSt: je **15. des Folgemonats**.
- KommSt-Jahreserklärung: über FinanzOnline **bis Ende März** des
Folgejahres.
- Lohnzettel L16 (§ 84 EStG): **Ende Februar** des Folgejahres via
ELDA (Papierformular ohne technische Voraussetzungen: Ende Jänner);
berichtigter Lohnzettel nach steuerlich relevanter Ergänzung des
Lohnkontos **binnen 14 Tagen**.
- Arbeitsstättenmeldung § 34 Abs 6 ASVG: **Ende Februar** (via ELDA
an Statistik Austria).
- § 109a-EStG-Meldung: **Ende Februar** (ELDA); Papierformular E 109a
+ Kopie an den Entgeltempfänger Ende Jänner (nicht implementiert —
Honorar-Basis).
- Schwerarbeitsmeldung ASchV § 5: Jänner bis **Ende Februar** via
ELDA (nicht implementiert — blockiert, ASchV-Datenmodell fehlt).
Odoo-Integration: `mail.activity.mixin` für optionale Benachrichtigung
bei Überschreitung; native Fristart-Definition mit Sprungaktion zur
GP7-Deklaration (L16/mBGM erzeugen); Auto-Erzeugung per ir.cron
(monatlich am 1.: offene SV/DB/DZ/KommSt-Fristen des Vormonats;
jährlich am 1. Jänner: Jahresfristen des Vorjahres)."""
from datetime import date, timedelta
from odoo import api, fields, models
FRISTART_DEFINITIONEN = {
'sv_mbgm': {
'name': 'SV-Beiträge (mBGM) einlangen',
'monatlich': True,
'tag': 15,
'action': 'mbgm',
'hilfetext': '15. des Folgemonats (Gutschriftprinzip; '
'Vorschreibebetriebe: 7.). Verzugszinsen ab dem '
'16. Tag.',
},
'db_dz_kommst': {
'name': 'DB/DZ/KommSt einlangen',
'monatlich': True,
'tag': 15,
'action': None,
'hilfetext': 'FLAF-Dienstgeberbeitrag, Zuschlag zum '
'Dienstgeberbeitrag und Kommunalsteuer: je 15. '
'des Folgemonats.',
},
'kommst_jahres': {
'name': 'KommSt-Jahreserklärung (FinanzOnline)',
'monatlich': False,
'monat': 3,
'tag_im_monat': 31,
'action': None,
'hilfetext': 'Jahreserklärung der Kommunalsteuer über '
'FinanzOnline bis Ende März des Folgejahres '
'(§ 11 Abs 5 KommStG).',
},
'l16': {
'name': 'Lohnzettel L16 (§ 84 EStG)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': 'l16',
'hilfetext': 'Jahres-Lohnzettel bis Ende Februar des '
'Folgejahres via ELDA; Papierformular ohne '
'technische Voraussetzungen: Ende Jänner. '
'Berichtigter Lohnzettel nach steuerlich '
'relevanter Ergänzung des Lohnkontos binnen '
'14 Tagen.',
},
'arbeitsstaette': {
'name': 'Arbeitsstättenmeldung (§ 34 Abs 6 ASVG)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': 'l16',
'hilfetext': 'Adresse der Arbeitsstätte am 31. 12. bis '
'Ende Februar des Folgejahres via ELDA (VSTR '
'ST). Läuft im GP7-Jahreslauf mit der L16.',
},
'paragraph_109a': {
'name': '§ 109a-EStG-Meldung (Honorare)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': None,
'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).',
},
'schwerarbeit': {
'name': 'Schwerarbeitsmeldung (ASchV § 5)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': None,
'hilfetext': 'Jährlich rückwirkend Jänner bis Ende Februar '
'via ELDA an den KV-Träger; meldepflichtig '
'ab 40 M / 35 F (⚠ blockiert — ASchV-Daten-'
'modell fehlt im Modul).',
},
}
class L10nAtPayrollFrist(models.Model):
_name = 'l10n.at.payroll.frist'
_description = 'Lohnverrechnungs-Fristenkalender'
_inherit = ['mail.thread']
_order = 'deadline, id'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company, index=True)
fristart = fields.Selection(
[(k, v['name']) for k, v in FRISTART_DEFINITIONEN.items()],
required=True, index=True)
jahr = fields.Integer(
required=True, string='Bezugs-/Kalenderjahr',
help='Beitragsjahr (monatliche Fristen) bzw. gemeldetes '
'Jahr (Jahresfristen: L16/Arbeitsstätte/§-109a gelten '
'für das Vorjahr).')
monat = fields.Integer(
string='Beitragsmonat (monatliche Fristen)',
help='112; leer bei Jahresfristen.')
deadline = fields.Date(required=True, string='Frist')
state = fields.Selection(
[('offen', 'Offen'), ('erledigt', 'Erledigt'),
('ueberfaellig', 'Überfällig')],
compute='_compute_state', store=True, index=True)
erledigt_am = fields.Date(readonly=True)
hilfetext = fields.Text(readonly=True)
ref_l16_id = fields.Many2one(
'l10n.at.payroll.l16', string='L16-Deklaration',
help='Sprungziel für Fristen mit L16/Arbeitsstätten-Meldung.')
_sql_constraints = [(
'frist_company_art_jahr_monat_uniq',
'unique(company_id, fristart, jahr, monat)',
'Diese Frist existiert bereits für das Unternehmen.')]
@api.depends('deadline', 'erledigt_am')
def _compute_state(self):
heute = fields.Date.today()
for frist in self:
if frist.erledigt_am:
frist.state = 'erledigt'
elif frist.deadline and frist.deadline < heute:
frist.state = 'ueberfaellig'
else:
frist.state = 'offen'
def action_erledigt(self):
self.write({'erledigt_am': fields.Date.today()})
return True
def action_wieder_eroeffnen(self):
self.write({'erledigt_am': False})
return True
def action_zum_objekt(self):
"""Sprung zur GP7-Deklaration (L16 oder mBGM)."""
self.ensure_one()
if self.ref_l16_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'l10n.at.payroll.l16',
'res_id': self.ref_l16_id.id,
'view_mode': 'form',
}
return True
@api.model
def _cron_fristen_erzeugen(self):
"""Monatlich am 1.: offene Fristen des Vormonats (SV/DB/DZ/
KommSt) + jährlich am 1. Jänner: Jahresfristen des
Vorjahres. Idempotent (Unique-Constraint)."""
heute = fields.Date.today()
vormonat = (heute.replace(day=1) - timedelta(days=1))
for company in self.env['res.company'].search([]):
# Monatliche Fristen des Vormonats (15. des Folgemonats).
folgemonat = heute.replace(day=1)
deadline_monat = date(
folgemonat.year, folgemonat.month,
FRISTART_DEFINITIONEN['sv_mbgm']['tag'])
for art in ('sv_mbgm', 'db_dz_kommst'):
self._sicherstellen(
company, art, vormonat.year, vormonat.month,
deadline_monat)
# Jahresfristen nur am 1. Jänner (für das Vorjahr).
if heute.month == 1:
for art in ('l16', 'arbeitsstaette', 'paragraph_109a',
'schwerarbeit'):
monat = FRISTART_DEFINITIONEN[art]['monat']
tag = FRISTART_DEFINITIONEN[art]['tag_im_monat']
jahr_deadline = heute.year
deadline_jahr = date(jahr_deadline, monat, tag)
if deadline_jahr.month == 2 and tag > 28:
deadline_jahr = date(jahr_deadline, monat, 28)
self._sicherstellen(
company, art, heute.year - 1, None,
deadline_jahr)
# KommSt-Jahreserklärung (Ende März).
self._sicherstellen(
company, 'kommst_jahres', heute.year - 1, None,
date(heute.year, 3, 31))
@api.model
def _sicherstellen(self, company, art, jahr, monat, deadline):
vals = {
'company_id': company.id,
'fristart': art,
'jahr': jahr,
'monat': monat,
'deadline': deadline,
'hilfetext': FRISTART_DEFINITIONEN[art]['hilfetext'],
}
bestehend = self.search([
('company_id', '=', company.id),
('fristart', '=', art),
('jahr', '=', jahr),
('monat', '=', monat)])
if not bestehend:
self.create(vals)
return bestehend or self.env['l10n.at.payroll.frist']
@@ -0,0 +1,152 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Jahres-Retro-/Aufrollungslauf (GP8/AP8): Kontrollsechstel-
Verpflichtungslauf (§ 67 Abs 10 EStG) und optionale §-77-Abs-3/4-
Aufrollung mit YTD-Rekalkulation.
Kuratierte Grundlagen (lb-lvr-07, lb-naz-01…04, 2026-06/08):
- **Kontrollsechstel** (§ 67 Abs 10): sonstige Bezüge innerhalb des
Jahressechstels sind mit Fixsätzen versteuert; bei Überhang wird
die **Differenz tarifisch** versteuert — der Dezember-/
Beendigungsabrechnungslauf rechnet die SZ-Steuer mit dem Tarif neu
(**Günstig-Aufrollung** — GP3-Engine kann beides bereits).
- **§ 77 Abs 3/4**: optionale Aufrollung der Lohnsteuer des
Kalenderjahres beim Jahreswechsel (15.-Jänner-Grenze;
Krankengeldbezug im Jahr blockt Abs 3/4, nicht Abs 4a). Die
Aufrollung rechnet die laufende Lohnsteuer kumulativ neu und
verteilt die Differenz auf die verbleibenden Monate.
- **§ 77 Abs 4a**: Aufrollung nach §-67-Ausschüttungen (nachträg-
liche SZ-Steuer mit Günstig-Vergleich).
- **15.2.-Cutoff** (lb-naz-02/03): Zahlung bis 15. 2. des Folgejahres
→ Vorjahreszuordnung (berichtigter Lohnzettel binnen 14 Tagen);
danach keine Zuordnung mehr (§ 67 Abs 8 lit c Fünftel oder
§ 67 Abs 1 Tarif).
- **SV rollt stets auf den Beitragsmonat der Fälligkeit** (GP4:
`l10n_at_beitragsmonat` je Regel/Linie, historisch versionierte
Sätze/HBG). mBGM-Storno/Neumeldung als Folgeaktion (GP7 `art='ST'`).
Odoo-Mechanik: der Wizard **erzeugt keine eigenen Korrekturdaten** —
er analysiert die Jahresdaten, zeigt den Handlungsbedarf und
empfiehlt die Odoo-native Korrektur-Pipeline (Refund-Payslips über
`_action_refund_payslips()` + Neuberechnung des betroffenen Monats
mit korrigierten Eingangsdaten; die GP3-Engine rechnet YTD
automatisch um)."""
from datetime import date
from odoo import api, fields, models
from odoo.exceptions import UserError
class L10nAtPayrollJahresretroWizard(models.TransientModel):
_name = 'l10n.at.payroll.jahresretro.wizard'
_description = 'Jahres-Retro-/Aufrollungslauf (GP8)'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
jahr = fields.Integer(
required=True, default=lambda self: fields.Date.today().year - 1,
string='Jahr (Kontroll-/Aufrollungslauf)')
zeige_detail = fields.Boolean(
default=True, string='Detail je Mitarbeiter anzeigen')
nur_kontrollsechstel = fields.Boolean(
default=True, string='Nur Kontrollsechstel-Verpflichtungslauf '
'prüfen (§ 67 Abs 10)')
aufrollung_77 = fields.Boolean(
default=False,
string='§-77-Abs-3/4-Aufrollung prüfen',
help='Optionale Aufrollung der laufenden Lohnsteuer des '
'Kalenderjahres (nur ohne Krankengeldbezug; 15.-Jänner-'
'Grenze für die Zahlung; Abs 4a nach §-67-Ausschüttungen '
'unabhängig prüfbar).')
ergebnis_ids = fields.One2many(
'l10n.at.payroll.jahresretro.ergebnis',
'wizard_id', string='Prüfergebnis je Mitarbeiter')
zusammenfassung = fields.Text(readonly=True)
def action_pruefen(self):
for wizard in self:
wizard.ergebnis_ids.unlink()
slips = wizard._slips_des_jahres()
ergebnisse = []
for slip in slips:
ergebnis = wizard._pruefe_mitarbeiter(slip)
if ergebnis:
ergebnisse.append(ergebnis)
wizard.ergebnis_ids = [
(0, 0, e) for e in ergebnisse]
wizard.zusammenfassung = wizard._zusammenfassung()
return True
def _slips_des_jahres(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')),
]).sorted('date_from')
def _pruefe_mitarbeiter(self, slip):
"""Prüft Kontrollsechstel-Überhang und optional §-77-Aufrollung
je AN. Liefert None, wenn kein Handlungsbedarf."""
self.ensure_one()
sechstel = slip._l10n_at_sechstel()
sonstige_basis = slip._l10n_at_brutto('sonstig')
laufende_basis = slip._l10n_at_brutto('laufend')
# §-67-Bezüge über dem Jahressechstel → Überhang.
if self.nur_kontrollsechstel \
and sonstige_basis > sechstel:
ueberhang = sonstige_basis - sechstel
fixsatz_st = slip._l10n_at_sz_tarifisch_aktuell()
tarif_st = slip._l10n_at_lst_sonstige()
gut = fixsatz_st <= tarif_st
return {
'employee_id': slip.employee_id.id,
'kontrollsechstel_ueberhang': ueberhang,
'fixsatz_steuer': fixsatz_st,
'tarif_steuer': tarif_st,
'guenstig': gut,
'handlung': (
'Keine Korrektur — Fixsätze sind günstiger'
if gut else
'Aufrollung: Dezemberlauf rechnet die SZ-Steuer '
'mit dem Tarif neu (§ 67 Abs 10 Günstig-Aufrollung). '
'SV rollt auf den Beitragsmonat (GP4); mBGM-Storno '
'(GP7) wenn der Beitragsmonat wechselt.'),
}
return None
def _zusammenfassung(self):
self.ensure_one()
ohne = self.ergebnis_ids.filtered(lambda r: r.guenstig)
mit = self.ergebnis_ids.filtered(lambda r: not r.guenstig)
return (
'Kontrollsechstel-Prüfung %d: %d Mitarbeiter mit '
'Überhang, davon %d mit Günstig-Aufrollung (= Tarif '
'günstiger als Fixsätze → Korrekturlauf).\n'
'§-77-Prüfung: %s.'
% (self.jahr, len(self.ergebnis_ids), len(mit),
'aktiviert' if self.aufrollung_77 else 'deaktiviert'))
class L10nAtPayrollJahresretroErgebnis(models.TransientModel):
_name = 'l10n.at.payroll.jahresretro.ergebnis'
_description = 'Jahres-Retro — Prüfergebnis je AN'
wizard_id = fields.Many2one(
'l10n.at.payroll.jahresretro.wizard', required=True,
ondelete='cascade')
employee_id = fields.Many2one('hr.employee', required=True)
kontrollsechstel_ueberhang = fields.Monetary(
currency_field='currency_id')
fixsatz_steuer = fields.Monetary(
currency_field='currency_id',
string='SZ-Steuer (Fixsätze)')
tarif_steuer = fields.Monetary(
currency_field='currency_id',
string='SZ-Steuer (Tarif)')
guenstig = fields.Boolean(
string='Fixsätze günstiger (keine Korrektur)')
handlung = fields.Text(string='Handlungsempfehlung')
currency_id = fields.Many2one(
'res.currency', related='wizard_id.company_id.currency_id')
+104
View File
@@ -0,0 +1,104 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Kind-Datenmodell (GP7): Kinder je Mitarbeiter für die Wartung des
Familienbonus Plus und die Befüllung des L16-Kinderblocks
(Lohnzettel-Finanz-Mitteilungssatz, DM-ORG 42.7.0 E.14, Felder
Nr. 196209: je Kind 123 Stellen — KFAM/KVON/KSTAAT/KSTWE/KVSNR/
KGEBD/KAFBZ/KAPFB/KAUHZ/KBGFP/KEGFP/KBHFP/KEHFP).
Nutzer-Entscheidung 2026-09-12: die FB+-Steuerwirkung selbst ist KEIN
Bestandteil der Personalverrechnung (bei Unterschreiten der Steuer auf 0
erfolgt die Geltendmachung über die Arbeitnehmerveranlagung) — das Modell
dient der Stammdaten-Wartung (Anspruch, Monate, halber Bonus) und der
L16-Meldung. Monatsfelder im Format MM (Kinderblock 2n); die
Betragsermittlung (FABO) rechnet der L16-Generator aus den gepflegten
Monaten und der Altersstaffel (§ 33 Abs 5 EStG: bis 18. Geburtstag
166,68 €/Monat, darüber 58,34 €/Monat solange Familienbeihilfe)."""
from odoo import fields, models
class HrEmployee(models.Model):
_inherit = 'hr.employee'
l10n_at_payroll_kind_ids = fields.One2many(
'l10n.at.payroll.kind', 'employee_id',
string='Kinder (Familienbonus Plus)',
groups="hr_payroll.group_hr_payroll_user")
class L10nAtPayrollKind(models.Model):
_name = 'l10n.at.payroll.kind'
_description = 'Kind (Familienbonus Plus / Lohnzettel L16)'
_order = 'geburtstag, id'
employee_id = fields.Many2one(
'hr.employee', string='Mitarbeiter:in', required=True,
index=True, ondelete='cascade')
familienname = fields.Char(
string='Familienname (KFAM)', required=True)
vorname = fields.Char(string='Vorname (KVON)')
geburtstag = fields.Date(
string='Geburtsdatum (KGEBD)', required=True,
help='Maßgeblich für die FB+-Staffel (bis zum 18. Geburtstag '
'166,68 €/Monat, danach 58,34 €/Monat solange '
'Familienbeihilfe) und die Altersfilter (z. B. '
'Schwerarbeitsmeldung 40 M/35 F).')
versicherungsnummer = fields.Char(
string='Versicherungsnummer (KVSNR)',
help='10-stellig, LLLPTTMMJJ; nur wenn zugeteilt.')
staatsangehoerigkeit = fields.Char(
string='Staatsangehörigkeit (KSTAAT, 3-stelliger Code)')
staatsangehoerigkeit_2 = fields.Char(
string='2. Staatsangehörigkeit (KSTWE, 1-stelliger Code)')
# ------------------------------------------------------------------
# Familienbonus Plus — Wartung (L16-Kinderblock)
# ------------------------------------------------------------------
fab_plus = fields.Boolean(
string='Familienbonus Plus geltend gemacht',
help='L16 KFABO (Anzahl) und FABO (Betrag) ergeben sich aus den '
'Kindern mit gesetztem FB+-Kennzeichen.')
fab_monat_von = fields.Integer(
string='FB+ ab Monat (KBGFP)',
help='Monat (112), ab dem der volle Familienbonus Plus für '
'dieses Kind geltend gemacht wurde (L16 KBGFP).')
fab_monat_bis = fields.Integer(
string='FB+ bis Monat (KEGFP)',
help='Letzter Monat (112) des vollen Familienbonus Plus (L16 '
'KEGFP).')
fab_halb_monat_von = fields.Integer(
string='Halber FB+ ab Monat (KBHFP)',
help='Monat (112), ab dem nur mehr der halbe Familienbonus Plus '
'geltend gemacht wurde (Aufteilung zwischen den '
'Elternteilen; L16 KBHFP).')
fab_halb_monat_bis = fields.Integer(
string='Halber FB+ bis Monat (KEHFP)',
help='Letzter Monat (112) des halben Familienbonus Plus (L16 '
'KEHFP).')
def _l10n_at_fab_monate(self, jahr):
"""[(Monat, Anteil 1.0/0.5), …] des FB+ im Lohnzetteljahr aus den
gepflegten Monatsfeldern (voll + halb, Überschneidung: der
spätere Eintrag gewinnt)."""
self.ensure_one()
monate = {}
von, bis = self.fab_monat_von or 0, self.fab_monat_bis or 0
if von and bis and 1 <= von <= bis <= 12:
for monat in range(von, bis + 1):
monate[monat] = 1.0
hvon, hbis = (self.fab_halb_monat_von or 0,
self.fab_halb_monat_bis or 0)
if hvon and hbis and 1 <= hvon <= hbis <= 12:
for monat in range(hvon, hbis + 1):
monate[monat] = 0.5
return sorted(monate.items())
def _l10n_at_fab_betrag(self, jahr, voll=166.68, vermindert=58.34):
"""FB+-Jahresbetrag je Kind aus Monaten × Monatsrate; die Rate
wechselt mit dem 18. Geburtstag (monatliche Staffel: Monate vor
dem Geburtsmonat + 18 Jahre voller Satz, danach verminderter
Satz — § 33 Abs 5 EStG, solange Familienbeihilfe)."""
self.ensure_one()
if not self.geburtstag or not self.fab_plus:
return 0.0
umschalt = self.geburtstag.month
return sum(anteil * (vermindert if monat >= umschalt else voll)
for monat, anteil in self._l10n_at_fab_monate(jahr))
@@ -0,0 +1,774 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Lohnzettel Finanz (L16) und Arbeitsstättenmeldung (GP7/AP5) —
Jahresdeklaration nach DM-ORG 42.7.0 (lokal `.firecrawl/ris/elda/`).
- **L16** (BEST `LF`, zuständiger Träger VSTR `94` Bundesrechenzentrum):
Informationssatz I1 (SART `I1`, Länge 1100, Strukturversion 03) +
Mitteilungssatz L1 (SART `L1`, Länge 3500, Lohnzettelversion 28 —
zwingend ab 01.03.2026). KZ-Struktur nach lb-lvr-04 (✅
RECHTSQUELLEN-Privat Abschn. 10): **KZ 245 = 210 215 220 230
243**; die KZ-Werte sind aus den Kern-Flags (`l10n_at_bemessung`,
`steuerbefreiung_68`, `s67_abs`) und Regelcodes ableitbar — bewusst
KEIN paralleles KZ-Klassifikationsfeld (Widerspruchsrisiko zur
GP3-Steuerlogik).
- **Arbeitsstättenmeldung** § 34 Abs 6 ASVG (BEST `AD`, VSTR `ST`
Statistik Austria): Satzart 45 (Länge 300, Version 02) je AN mit der
Adresse der Arbeitsstätte am 31. 12. — läuft im selben Jahreslauf
(Nutzer-Entscheidung 5.1; Frist gemeinsam Ende Februar).
- Familienbonus Plus: **keine** Personalverrechnungs-Komponente
(Unterschreiten der Steuer auf 0 → Arbeitnehmerveranlagung;
Nutzer-Entscheidung 2026-09-12); die FB+-Felder des L1
(BFABO/KFABO/VFABO/FABO + Kinderblock) werden aus der
Stammdaten-Wartung am Kind-Modell (`l10n.at.payroll.kind`) befüllt.
- Dateibasiert (Prämisse P7): Generierung + formale Prüfung als
Prüfprotokoll; Übertragung/ELDA-Konto beim Mandanten.
Satzstrukturen: PDF-verifizierte Extraktion 2026-09-12 (DM-ORG 42.7.0,
Kapitel E.13/E.14/E.20; Positions-Ketten lückenlos geprüft)."""
from datetime import date, datetime
import base64
import logging
from odoo import api, fields, models
from odoo.exceptions import UserError
from .elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
)
_logger = logging.getLogger(__name__)
def l10n_at_namen(employee):
"""(Familienname, Vorname) aus dem Odoo-Gesamtnamen: Split am
letzten Leerzeichen (österreichische Schreibweise „Vorname
Nachname“; mehrgliedrige Familiennamen werden als ein Feld
geführt — eSV FANA/VONA-Verifikation im Parallellauf)."""
name = (employee.name or '').strip()
if ' ' in name:
vorname, familienname = name.rsplit(' ', 1)
return familienname, vorname
return name, ''
# ------------------------------------------------------------------
# Informationssatz I1 (E.13, S. 219220; Länge 1100) — Datenteil
# ------------------------------------------------------------------
L16_INFORMATIONSSATZ = [
(2, 'FSART', 21, 1, 'a'), # I
(3, 'CLEAR', 22, 6, 'a'), # Clearingstelle (RADAUS/OESTAT)
(4, 'CLDVR', 28, 7, 'a'),
(6, 'STNRL', 35, 9, 'n'), # Steuernummer Lohnverrechnungsstelle
(7, 'DVRNL', 44, 7, 'n'),
(9, 'STNRA', 51, 9, 'n'), # Steuernummer Arbeitgeber
(10, 'DVRNA', 60, 7, 'n'),
(11, 'ARTD', 67, 2, 'a'), # LZ = Lohnzetteldaten
(12, 'DTUE', 69, 8, 'n'), # JJJJMMTT
(13, 'ZTUE', 77, 6, 'n'), # HHMMSS
(14, 'STVE', 83, 2, 'n'), # Strukturversion 03
(15, 'ANAM', 85, 37, 'a'), # AG-Name
(16, 'ATIT', 122, 20, 'a'),
(17, 'AADR', 142, 37, 'a'),
(18, 'ALKZ', 179, 3, 'a'),
(19, 'APLZ', 182, 10, 'a'),
(20, 'AORT', 192, 30, 'a'),
(21, 'INTE', 222, 11, 'a'),
(22, 'JAHR', 233, 4, 'n'), # Mitteilungszeitraum JJJJ
(23, 'GESA', 237, 7, 'n'), # Gesamtanzahl Mitteilungen im Jahr
(24, 'ANZA', 244, 7, 'n'), # Anzahl der aktuellen Übermittlung
(25, 'ATEL', 251, 16, 'a'),
(26, 'AFAX', 267, 16, 'a'),
(27, 'RESE', 283, 818, 'a'),
]
# ------------------------------------------------------------------
# Mitteilungssatz L1 (E.14, S. 223233; Länge 3500) — Datenteil inkl.
# RESE-Grundstellungsfeldern (n-Felder Grundstellung 0!)
# ------------------------------------------------------------------
L16_MITTEILUNGSSATZ = [
(2, 'FSART', 21, 1, 'a'), # L
(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'), # Art der Übermittlung (Prüfkatalog)
(9, 'DTUE', 52, 8, 'n'), # JJJJMMTT, wie I1
(10, 'ZTUE', 60, 6, 'n'), # wie I1
(11, 'REFN', 66, 30, 'a'), # je AG+Jahr+DV eindeutig
(12, 'ARTL', 96, 2, 'n'), # Art des Lohnzettels (D.31)
(13, 'FEHL', 98, 20, 'a'), # Clearingstelle
(14, 'STAT', 118, 1, 'a'), # Clearingstelle
(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, 'SOZS', 132, 1, 'n'), # soziale Stellung (D.33)
(20, 'AVLN', 133, 4, 'n'), # VSNR LLLP
(21, 'AGBD', 137, 6, 'n'), # TTMMJJ
(22, 'ANAM', 143, 37, 'a'),
(23, 'ATIT', 180, 20, 'a'),
(24, 'AADR', 200, 37, 'a'),
(25, 'ALKZ', 237, 3, 'a'),
(26, 'APLZ', 240, 10, 'a'),
(27, 'AORT', 250, 30, 'a'),
(28, 'GESW', 280, 1, 'a'),
(29, 'GESM', 281, 1, 'a'),
(30, 'GESD', 282, 1, 'a'),
(31, 'VOLL', 283, 1, 'a'), # 'J' überwiegend Vollzeit
(32, 'TEIL', 284, 1, 'a'), # 'J' überwiegend Teilzeit
(33, 'AVAB', 285, 1, 'a'), # Alleinerzieherabsetzbetrag
(34, 'PVLN', 286, 4, 'n'), # Partner-VSNR LLLP (AVB)
(35, 'PGBD', 290, 6, 'n'), # TTMMJJ (AVB)
(36, 'AEAB', 296, 1, 'a'), # Alleinverdienerabsetzbetrag
(37, 'V210', 297, 1, 'a'),
(38, 'B210', 298, 10, 'n'), # KZ 210 § 25 (Cent)
(39, 'V215', 308, 1, 'a'),
(40, 'B215', 309, 10, 'n'), # KZ 215 § 68-steuerfrei
(41, 'V220', 319, 1, 'a'),
(42, 'B220', 320, 10, 'n'), # KZ 220 § 67 Abs 1/2/5 (inkl. 620-€)
(43, 'VIEB', 330, 1, 'a'),
(44, 'BIEB', 331, 10, 'n'), # insgesamt einbehaltene Beiträge
(45, 'V225', 341, 1, 'a'),
(46, 'B225', 342, 10, 'n'), # KZ 225 SV zu KZ 220
(47, 'V226', 352, 1, 'a'),
(48, 'B226', 353, 10, 'n'), # KZ 226 SV zu § 67 Abs 38
(49, 'V230', 363, 1, 'a'),
(50, 'B230', 364, 10, 'n'), # KZ 230 laufende SV/AK/WF
(51, 'RESE51', 374, 1, 'a'),
(52, 'RESE52', 375, 10, 'n'),
(53, 'VAUS', 385, 1, 'a'),
(54, 'BAUS', 386, 10, 'n'),
(55, 'VPEN', 396, 1, 'a'),
(56, 'BPEN', 397, 10, 'n'),
(57, 'VEFB', 407, 1, 'a'),
(58, 'BEFB', 408, 10, 'n'),
(59, 'VSTF', 418, 1, 'a'),
(60, 'BSTF', 419, 10, 'n'),
(61, 'VSSB', 429, 1, 'a'),
(62, 'BSSB', 430, 10, 'n'),
(63, 'V243', 440, 1, 'a'),
(64, 'B243', 441, 10, 'n'), # KZ 243 sonstige steuerfreie
(65, 'V245', 451, 1, 'a'),
(66, 'B245', 452, 10, 'n'), # KZ 245 steuerpflichtige Bezüge
(67, 'VIEL', 462, 1, 'a'),
(68, 'BIEL', 463, 10, 'n'), # insgesamt einbehaltene Lohnsteuer
(69, 'VABL', 473, 1, 'a'),
(70, 'BABL', 474, 10, 'n'), # abzügliche Lohnsteuer (Fixsätze)
(71, 'V260', 484, 1, 'a'),
(72, 'B260', 485, 10, 'n'), # KZ 260 anrechenbare Lohnsteuer
(73, 'VSOB', 495, 1, 'a'),
(74, 'BSOB', 496, 10, 'n'),
(75, 'VFB1', 506, 1, 'a'),
(76, 'BFB1', 507, 10, 'n'),
(77, 'RESE77', 517, 10, 'a'),
(78, 'VFB2', 527, 1, 'a'),
(79, 'BFB2', 528, 10, 'n'),
(80, 'VAUF', 538, 1, 'a'),
(81, 'BAUF', 539, 10, 'n'),
(82, 'VFB3', 549, 1, 'a'),
(83, 'BFB3', 550, 10, 'n'),
(84, 'VNEB', 560, 1, 'a'),
(85, 'BNEB', 561, 10, 'n'),
(86, 'PVON', 571, 2, 'n'),
(87, 'PBIS', 573, 2, 'n'),
(88, 'VPFG', 575, 1, 'a'),
(89, 'BPFG', 576, 10, 'n'),
(90, 'VSFB', 586, 1, 'a'),
(91, 'BSFB', 587, 10, 'n'),
(92, 'RESE92', 597, 1, 'a'),
(93, 'RESE93', 598, 10, 'n'),
(94, 'RESE94', 608, 1, 'a'),
(95, 'RESE95', 609, 10, 'n'),
(96, 'RESE96', 619, 1, 'a'),
(97, 'RESE97', 620, 10, 'n'),
(98, 'RESE98', 630, 1, 'a'),
(99, 'RESE99', 631, 8, 'n'),
(101, 'STNRA', 639, 9, 'n'), # auszahlende Stelle (Pensionen)
(102, 'DVRNA', 648, 7, 'n'),
(103, 'ANME', 655, 1, 'a'), # Unterbrechung LZZR
(104, 'KORR', 656, 1, 'a'), # 'K' berichtigter Lohnzettel
(105, 'VUEB', 657, 1, 'a'),
(106, 'BUEB', 658, 10, 'n'),
(107, 'INTE', 668, 11, 'a'),
(108, 'WOBD', 679, 12, 'a'), # Ordnungsbegriff DG (Personalnummer)
(109, 'ZOBD', 691, 15, 'a'),
(110, 'ANZK', 706, 2, 'n'), # Anzahl Kinder § 106
(111, 'RESE111', 708, 1, 'a'),
(112, 'RESE112', 709, 1, 'a'),
(113, 'RESE113', 710, 1, 'a'),
(114, 'RESE114', 711, 1, 'a'),
(115, 'RESE115', 712, 1, 'a'),
(116, 'RESE116', 713, 1, 'a'),
(117, 'RESE117', 714, 1, 'a'),
(118, 'RESE118', 715, 1, 'a'),
(119, 'RESE119', 716, 1, 'a'),
(120, 'RESE120', 717, 1, 'a'),
(121, 'RESE121', 718, 1, 'a'),
(122, 'RESE122', 719, 1, 'a'),
(123, 'VAPK', 720, 1, 'a'),
(124, 'BAPK', 721, 10, 'n'),
(125, 'EPAB', 731, 1, 'a'),
(126, 'VENT', 732, 1, 'a'),
(127, 'ENTW', 733, 10, 'n'),
(128, 'VPRE', 743, 1, 'a'),
(129, 'PREI', 744, 10, 'n'),
(130, 'WERK', 754, 2, 'n'),
(131, 'VPEND', 756, 1, 'a'),
(132, 'BPEND', 757, 10, 'n'),
(134, 'UEBMO', 767, 2, 'n'),
(135, 'RESE135', 769, 1, 'a'),
(136, 'RESE136', 770, 10, 'n'),
(137, 'LDKZA', 780, 3, 'a'),
(138, 'VWBKB', 783, 1, 'a'),
(139, 'WBKB', 784, 10, 'n'),
(140, 'RESE140', 794, 1, 'a'),
(141, 'RESE141', 795, 10, 'a'),
(142, 'ERVAB', 805, 1, 'a'),
(143, 'BFABO', 806, 1, 'a'), # Familienbonus Plus 'J'
(144, 'KFABO', 807, 2, 'n'), # Anzahl Kinder FB+
(145, 'VFABO', 809, 1, 'a'),
(146, 'FABO', 810, 10, 'n'), # FB+-Betrag
(147, 'HOTA', 820, 3, 'n'), # Telearbeitstage
(148, 'VHOPA', 823, 1, 'a'),
(149, 'HOPA', 824, 10, 'n'), # Homeoffice-Pauschale § 26 Z 9 lit a
(150, 'VKOUN', 834, 1, 'a'),
(151, 'KOUN', 835, 10, 'n'),
(152, 'VMAGB', 845, 1, 'a'),
(153, 'MAGB', 846, 10, 'n'),
(154, 'FLABZ', 856, 1, 'a'), # freiwilliger Lohnsteuerabzug § 47
(155, 'AUEZG', 857, 1, 'a'),
(156, 'VTEPR', 858, 1, 'a'),
(157, 'TEPR', 859, 10, 'n'),
(158, 'VMAPR', 869, 1, 'a'),
(159, 'MAPR', 870, 10, 'n'),
(160, 'STUM', 880, 4, 'n'),
(161, 'STUMJ', 884, 4, 'n'),
(162, 'ZF673', 888, 1, 'a'),
(163, 'BDOZ', 889, 1, 'a'),
(164, 'VFSVB', 890, 1, 'a'),
(165, 'FSVB', 891, 10, 'n'),
(166, 'PLST', 901, 1, 'a'),
(167, 'VKAKL', 902, 1, 'a'),
(168, 'KAKL', 903, 10, 'n'),
(169, 'VZUKB', 913, 1, 'a'),
(170, 'ZUKB', 914, 10, 'n'),
(171, 'GEBD', 924, 8, 'n'),
(172, 'GEBP', 932, 8, 'n'),
(173, 'AKZKB', 940, 2, 'n'),
(174, 'RESE174', 942, 59, 'a'),
(175, 'REFN1', 1001, 30, 'a'),
(176, 'RESE176', 1031, 19, 'a'),
(177, 'BSBKFZ', 1050, 10, 'n'),
(178, 'BSBWR', 1060, 10, 'n'),
(179, 'BSBSB', 1070, 10, 'n'),
(180, 'SB681', 1080, 10, 'n'),
(181, 'SB682', 1090, 10, 'n'),
(182, 'BZUKG', 1100, 10, 'n'),
(183, 'MAKBG', 1110, 10, 'n'),
(184, 'MABST', 1120, 10, 'n'),
(185, 'ZUCSG', 1130, 10, 'n'),
(186, 'GUTSC', 1140, 10, 'n'),
(187, 'MARAB', 1150, 10, 'n'),
(188, 'SBK00', 1160, 1, 'a'),
(189, 'SBK15', 1161, 1, 'a'),
(190, 'SBK20', 1162, 1, 'a'),
(191, 'SBKDW', 1163, 1, 'a'),
(192, 'AKKFZ', 1164, 10, 'n'),
(193, 'KEAKFZ', 1174, 10, 'n'),
(194, 'AGLAG', 1184, 10, 'n'),
(195, 'RESE195', 1194, 307, 'a'),
(210, 'RESE210', 3346, 155, 'a'),
]
# Kinderblock (E.14, Felder 196209): je Kind 123 Stellen ab Pos. 1501
# (Block 1 = 15011623 … Block 15 = 32233345; Positionen aus den
# Feldlängen arithmetisch abgeleitet, PDF-verifiziert).
_KINDER_FELDER = [
(196, 'KFAM', 0, 30, 'a'), # Familienname
(197, 'KVON', 30, 30, 'a'), # Vorname
(198, 'KSTAAT', 60, 3, 'a'), # Staatsangehörigkeit
(199, 'KSTWE', 63, 1, 'a'), # 2. Staatsangehörigkeit
(200, 'KVSNR', 64, 10, 'n'), # VSNR des Kindes
(201, 'KGEBD', 74, 8, 'n'), # TTMMJJJJ
(202, 'KAFBZ', 82, 1, 'a'), # Kennzeichen FB+
(203, 'KAPFB', 83, 1, 'a'),
(204, 'KAUHZ', 84, 1, 'a'),
(205, 'KBGFP', 85, 2, 'n'), # FB+ ab Monat
(206, 'KEGFP', 87, 2, 'n'), # FB+ bis Monat
(207, 'KBHFP', 89, 2, 'n'), # halber FB+ ab Monat
(208, 'KEHFP', 91, 2, 'n'), # halber FB+ bis Monat
(209, 'RESEK', 93, 30, 'a'),
]
def _kinderblock_struktur():
felder = []
for kind_nr in range(1, 16):
basis = 1501 + (kind_nr - 1) * 123
for nr, name, offset, laenge, typ in _KINDER_FELDER:
felder.append(
(nr, '%s_%d' % (name, kind_nr), basis + offset,
laenge, typ))
return felder
L16_MITTEILUNGSSATZ += _kinderblock_struktur()
# ------------------------------------------------------------------
# Adresse der Arbeitsstätte § 34 Abs 6 ASVG (E.20; SART 45, Länge 300,
# Version 02; BEST AD, VSTR ST)
# ------------------------------------------------------------------
ARBEITSSTAETTE_SATZ = [
(2, 'KVTR', 21, 2, 'a'), # KV-Träger (aus BKNR-Präfix)
(3, 'BKNR', 23, 10, 'a'),
(4, 'VSNR', 33, 10, 'n'),
(5, 'BZVM', 43, 2, 'n'), # Beitragszeit von MM
(6, 'BZBM', 45, 2, 'n'), # Beitragszeit bis MM
(7, 'BZJJ', 47, 4, 'n'), # Beitragsjahr
(8, 'FAMS', 51, 2, 'a'), # Finanzamtsnummer meldende Stelle
(9, 'STNR', 53, 9, 'n'), # Steuernummer DG
(10, 'ASTR', 62, 50, 'a'), # Straße der Arbeitsstätte
(11, 'ANRV', 112, 5, 'a'), # Hausnummer von
(12, 'ANRB', 117, 5, 'a'), # Hausnummer bis
(13, 'ASTG', 122, 5, 'a'), # Stiege
(14, 'ATOP', 127, 5, 'a'), # Tür/Top
(15, 'APLZ', 132, 9, 'a'), # PLZ der Arbeitsstätte
(16, 'AORT', 141, 40, 'a'), # Ort
(17, 'AGKZ', 181, 5, 'n'), # Gemeindekennziffer
(18, 'APOL', 186, 50, 'a'), # Politische Gemeinde/Staat
(19, 'AUDT', 236, 8, 'n'), # TTMMJJJJ
(20, 'WOBD', 244, 12, 'a'),
(21, 'ZOBD', 256, 15, 'a'),
(22, 'REFN', 271, 30, 'a'),
]
class L10nAtPayrollL16Line(models.Model):
_name = 'l10n.at.payroll.l16.line'
_description = 'Lohnzettel Finanz — Mitteilungssatz-Daten je AN'
_order = 'employee_name'
l16_id = fields.Many2one(
'l10n.at.payroll.l16', 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()
soziale_stellung = fields.Char(help='08 (D.33)')
belz = fields.Char(string='LZP ab (TTMM)')
enlz = fields.Char(string='LZP bis (TTMM)')
kz210 = fields.Monetary(currency_field='currency_id')
kz215 = fields.Monetary(currency_field='currency_id')
kz220 = fields.Monetary(currency_field='currency_id')
kz225 = fields.Monetary(currency_field='currency_id')
kz226 = fields.Monetary(currency_field='currency_id')
kz230 = fields.Monetary(currency_field='currency_id')
kz243 = fields.Monetary(currency_field='currency_id')
kz245 = fields.Monetary(currency_field='currency_id')
lohnsteuer = fields.Monetary(currency_field='currency_id',
string='KZ 260 anrechenbare LSt')
beitraege = fields.Monetary(currency_field='currency_id',
string='Einbehaltene Beiträge (VIEB)')
fab_betrag = fields.Monetary(
currency_field='currency_id', string='Familienbonus Plus')
fab_kinder = fields.Integer(string='Anzahl Kinder FB+')
refn = fields.Char()
currency_id = fields.Many2one(
'res.currency', related='l16_id.company_id.currency_id')
def _kz_salden(self, slips):
"""KZ-Summen je AN aus validierten Läufen. **KZ 210 = Brutto-
bezüge gem. § 25 gesamt** (laufende UND sonstige §-67-Bezüge,
ohne § 26/§ 3 Abs 1 Z 16b — DM-ORG E.14 Feldbeschreibung), denn
KZ 245 = 210 215 220 230 243 ist die laufende Steuer-
bemessung: Gesamtbrutto §-68-Freie sonstige Bezüge
laufende SV-DN. 215 aus `_l10n_at_freibetraege_68` des
GP3-Kerns (Monats-Caps 400/600 und ÜStd 15/170 eingerechnet);
220 = sonstige § 67 Abs 1/2/5 ohne Abs-3/6; 225 = SV-DN auf
KZ 220; 230 = laufende SV-DN + AK + WF; VIEB = 230 + 225 +
226; 260/BIEL = LST_LFD + LST_SZ."""
self.ensure_one()
salden = dict.fromkeys(
('kz210', 'kz215', 'kz220', 'kz225', 'kz226', 'kz230',
'kz243', 'kz245', 'lohnsteuer', 'beitraege'), 0.0)
for slip in slips:
for line in slip.line_ids:
rule = line.salary_rule_id
if not line.total:
continue
if rule.l10n_at_bemessung == 'laufend':
salden['kz210'] += line.total
elif (rule.l10n_at_bemessung == 'sonstig'
and rule.l10n_at_s67_abs == 'keine'):
salden['kz210'] += line.total
salden['kz220'] += line.total
elif line.code == 'SVDN_SZ':
salden['kz225'] += line.total
elif line.code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV',
'SVDN_AK', 'SVDN_WF'):
salden['kz230'] += line.total
elif line.code in ('LST_LFD', 'LST_SZ'):
salden['lohnsteuer'] += line.total
freibe = slip._l10n_at_freibetraege_68()
salden['kz215'] += freibe['szfn'] + freibe['ue_std']
salden['beitraege'] = salden['kz230'] + salden['kz225'] \
+ salden['kz226']
salden['kz245'] = (salden['kz210'] - salden['kz215']
- salden['kz220'] - salden['kz230']
- salden['kz243'])
return salden
def _soziale_stellung(self, employee):
"""D.33: 1 Lehrling, 2 Arbeiter, 3 Angestellte (0 sonstige) —
MVP: Stand des aktuellen Vertrags."""
version = employee.version_id
if version and getattr(version, 'l10n_at_lehrling', False):
return '1'
if version and getattr(
version, 'l10n_at_personalgruppe', None) == 'arbeiter':
return '2'
return '3'
class L10nAtPayrollL16(models.Model):
_name = 'l10n.at.payroll.l16'
_description = 'Lohnzettel Finanz (L16) & Arbeitsstättenmeldung'
_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='Lohnzahlungszeitraum')
art = fields.Selection(
[('01', 'Lohnzettel (Jahresbeleg)'),
('18', 'Berichtigter Lohnzettel (binnen 2 Wochen)')],
required=True, default='01', tracking=True)
line_ids = fields.One2many(
'l10n.at.payroll.l16.line', 'l16_id', string='Mitteilungssätze')
datei_l16 = fields.Binary(
attachment=True, string='L16-Datei', copy=False)
datei_l16_name = fields.Char(copy=False)
datei_arbeitsstaette = fields.Binary(
attachment=True, string='Datei Arbeitsstättenmeldung',
copy=False)
datei_arbeitsstaette_name = fields.Char(copy=False)
protokoll = fields.Text(
string='Prüfprotokoll',
help='Formale Prüfung (Satzlängen, KZ-245-Formel, VSNR-Format, '
'Pflicht-Stammdaten) und Annahmen des Laufes; die '
'Fachprüfung bleibt der Clearingstelle (Mitteilungsfile, '
'GP8-Rest).')
state = fields.Selection(
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
required=True, tracking=True)
_sql_constraints = [(
'l16_company_jahr_art_uniq', 'unique(company_id, jahr, art)',
'Für dieses Jahr existiert bereits eine L16-Deklaration dieser '
'Art.')]
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')),
])
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):
"""V-Feld: blank bei 0, sonst '+'/'-' (L1-Feldregeln)."""
if not betrag:
return None
return '+' if betrag > 0 else '-'
def _l10n_at_l1_satz(self, line, stnr, heute, jetzt):
self.ensure_one()
employee = line.employee_id
version = employee.version_id
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L16_MITTEILUNGSSATZ, 3500,
bezeichnung='L1 %s' % employee.name)
satz.setze('SART', 'L1')
satz.setze('VSTR', '94')
satz.setze('FSART', 'L')
satz.setze('STNRL', stnr)
satz.setze('STNRA', stnr)
satz.setze_datum('DTUE', heute, 'JJJJMMTT')
satz.setze_zeit('ZTUE', jetzt)
satz.setze('REFN', line.refn)
satz.setze('ARTL', self.art)
if self.art == '18':
satz.setze('KORR', 'K')
satz.setze('FIND', 'J')
satz.setze('BELZ', line.belz)
satz.setze('ENLZ', line.enlz)
satz.setze('JALZ', self.jahr)
satz.setze('SOZS', line.soziale_stellung)
satz.setze('AVLN', (line.versicherungsnummer or '')[:4])
satz.setze_datum('AGBD', employee.birthday, 'TTMMJJ')
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 '')
if version and version.sex == 'female':
satz.setze('GESW', 'J')
elif version and version.sex == 'male':
satz.setze('GESM', 'J')
elif version and version.sex:
satz.setze('GESD', 'J')
vollzeit = bool(version and version.work_time_rate >= 1.0)
satz.setze('VOLL', 'J' if vollzeit else 'N')
satz.setze('TEIL', 'N' if vollzeit else 'J')
if version and version.l10n_at_familienabsetzbetrag == 'avab':
satz.setze('AVAB', 'J')
elif version \
and version.l10n_at_familienabsetzbetrag == 'avb':
satz.setze('AEAB', 'J')
for betrag, vfeld, bfeld in (
(line.kz210, 'V210', 'B210'),
(line.kz215, 'V215', 'B215'),
(line.kz220, 'V220', 'B220'),
(line.kz225, 'V225', 'B225'),
(line.kz226, 'V226', 'B226'),
(line.kz230, 'V230', 'B230'),
(line.kz243, 'V243', 'B243'),
(line.kz245, 'V245', 'B245'),
(line.beitraege, 'VIEB', 'BIEB'),
(line.lohnsteuer, 'VIEL', 'BIEL'),
(line.lohnsteuer, 'V260', 'B260')):
satz.setze(vfeld, self._vorzeichen(betrag))
satz.setze(bfeld, abs(betrag) if betrag else 0, cent=True)
satz.setze('WOBD', 'EMP-%d' % employee.id)
if version and version.children:
satz.setze('ANZK', version.children)
# Familienbonus Plus aus der Kind-Stammdaten-Wartung (keine
# Personalverrechnungs-Komponente — Nutzer-Entscheidung).
if line.fab_kinder:
satz.setze('BFABO', 'J')
satz.setze('KFABO', line.fab_kinder)
satz.setze('VFABO', self._vorzeichen(line.fab_betrag))
satz.setze('FABO', abs(line.fab_betrag) if line.fab_betrag
else 0, cent=True)
kinder = self.env['l10n.at.payroll.kind'].search([
('employee_id', '=', employee.id),
('fab_plus', '=', True)])
for nummer, kind in enumerate(kinder[:15], start=1):
satz.setze('KFAM_%d' % nummer,
(kind.familienname or '')[:30])
satz.setze('KVON_%d' % nummer, (kind.vorname or '')[:30])
satz.setze('KSTAAT_%d' % nummer, kind.staatsangehoerigkeit)
satz.setze('KSTWE_%d' % nummer,
kind.staatsangehoerigkeit_2)
satz.setze('KVSNR_%d' % nummer, kind.versicherungsnummer)
satz.setze_datum('KGEBD_%d' % nummer, kind.geburtstag,
'TTMMJJJJ')
satz.setze('KAFBZ_%d' % nummer, 'J')
satz.setze('KBGFP_%d' % nummer, kind.fab_monat_von)
satz.setze('KEGFP_%d' % nummer, kind.fab_monat_bis)
satz.setze('KBHFP_%d' % nummer, kind.fab_halb_monat_von)
satz.setze('KEHFP_%d' % nummer, kind.fab_halb_monat_bis)
return satz
def _l10n_at_l16_bestand(self, erstellungsdatum=None):
self.ensure_one()
heute = erstellungsdatum or date.today()
jetzt = datetime.now()
partner = self.company_id.partner_id
stnr = self.company_id.l10n_at_lohnsteuernummer
informationssatz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + L16_INFORMATIONSSATZ, 1100,
bezeichnung='L16-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', self.jahr)
informationssatz.setze('GESA', len(self.line_ids))
informationssatz.setze('ANZA', len(self.line_ids))
datensaetze = [informationssatz] + [
self._l10n_at_l1_satz(line, stnr, heute, jetzt)
for line in self.line_ids]
return L10nAtEldaBestand(
'LF', '94', '28', hersteller=self._l10n_at_hersteller(),
datensaetze=datensaetze)
def _l10n_at_arbeitsstaette_bestand(self, erstellungsdatum=None):
"""§ 34 Abs 6 ASVG je AN mit mBGM-Bezug: Adresse der
Arbeitsstätte am 31. 12. (Dienstort-Partner + GKZ)."""
self.ensure_one()
heute = erstellungsdatum or date.today()
bknr = self.company_id.l10n_at_beitragskontonummer
stnr = self.company_id.l10n_at_lohnsteuernummer
datensaetze = []
warnungen = []
for line in self.line_ids:
employee = line.employee_id
dienstort = employee.address_id
if not dienstort or not dienstort.l10n_at_gemeindekennziffer:
warnungen.append(
'Arbeitsstätte %s: Dienstort-Partner bzw. '
'Gemeindekennziffer fehlt (res.partner.'
'l10n_at_gemeindekennziffer am Dienstort-Partner '
'pflegen).' % employee.name)
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + ARBEITSSTAETTE_SATZ, 300,
bezeichnung='Arbeitsstätte %s' % employee.name)
satz.setze('SART', '45')
satz.setze('VSTR', 'ST')
satz.setze('KVTR', (bknr or '')[:2])
satz.setze('BKNR', bknr)
satz.setze('VSNR', line.versicherungsnummer)
satz.setze('BZVM', (line.belz or '01')[:2])
satz.setze('BZBM', (line.enlz or '12')[:2])
satz.setze('BZJJ', self.jahr)
satz.setze('FAMS', (stnr or '')[:2])
satz.setze('STNR', stnr)
satz.setze('ASTR', (dienstort.street if dienstort else '')
[:50])
satz.setze('APLZ', dienstort.zip if dienstort else '')
satz.setze('AORT', (dienstort.city if dienstort else '')[:40])
satz.setze('AGKZ',
dienstort.l10n_at_gemeindekennziffer
if dienstort else None)
satz.setze('APOL', (dienstort.city if dienstort else '')[:50])
satz.setze_datum('AUDT', heute, 'TTMMJJJJ')
satz.setze('WOBD', 'EMP-%d' % employee.id)
datensaetze.append(satz)
if not datensaetze:
return None, warnungen
return L10nAtEldaBestand(
'AD', 'ST', '02', hersteller=self._l10n_at_hersteller(),
datensaetze=datensaetze), warnungen
def action_erzeugen(self):
"""Lauf: KZ-Daten je AN aus validierten Läufen sammeln, formale
Prüfung (KZ-245-Formel, Pflicht-Stammdaten), Dateien erzeugen.
Fehler brechen ab (UserError, DmfA-Muster listenweise);
Warnungen landen im Prüfprotokoll."""
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, 9-stellig).')
if not company.l10n_at_beitragskontonummer:
fehler.append('Beitragskontonummer der Company fehlt '
'(l10n_at_beitragskontonummer, BKNR).')
slips = deklaration._l10n_at_slips()
nach_mitarbeiter = {}
for slip in slips:
nach_mitarbeiter.setdefault(slip.employee_id, []) \
.append(slip)
deklaration.line_ids.unlink()
zeilen_werte = []
for employee, employee_slips in nach_mitarbeiter.items():
version = employee.version_id
if not (version and version.ssnid):
fehler.append(
'Mitarbeiter %s ohne Versicherungsnummer '
'(hr.version.ssnid).' % employee.name)
continue
if not employee.birthday:
fehler.append(
'Mitarbeiter %s ohne Geburtsdatum.' % employee.name)
continue
line = deklaration.env['l10n.at.payroll.l16.line'].new({
'employee_id': employee.id})
salden = line._kz_salden(employee_slips)
erste = min(s.date_from for s in employee_slips)
letzte = max(s.date_to for s in employee_slips)
kinder = deklaration.env['l10n.at.payroll.kind'].search([
('employee_id', '=', employee.id),
('fab_plus', '=', True)])
zeilen_werte.append({
'employee_id': employee.id,
'versicherungsnummer': version.ssnid,
'soziale_stellung':
line._soziale_stellung(employee),
'belz': erste.strftime('%d%m'),
'enlz': letzte.strftime('%d%m'),
'refn': 'L16-%d-%d-%d' % (company.id,
deklaration.jahr,
employee.id),
'fab_kinder': len(kinder),
'fab_betrag': sum(
kind._l10n_at_fab_betrag(deklaration.jahr)
for kind in kinder),
**salden,
})
for werte in zeilen_werte:
deklaration.env['l10n.at.payroll.l16.line'].create(
dict(werte, l16_id=deklaration.id))
for line in deklaration.line_ids:
formel = (line.kz210 - line.kz215 - line.kz220
- line.kz230 - line.kz243)
if abs(formel - line.kz245) > 0.02:
fehler.append(
'%s: KZ-245-Formel verletzt (%.2f%.2f).'
% (line.employee_name, formel, line.kz245))
if line.kz245 < 0:
warnungen.append(
'%s: KZ 245 negativ (Bezüge übersteigen die '
'Abzüge) — FinOnl-Konsistenzprüfung prüft.'
% line.employee_name)
if fehler:
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
'\n'.join(fehler), '\n'.join(warnungen) or '')
raise UserError('L16-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler))
if deklaration.line_ids:
datei = L10nAtEldaDatei(
[deklaration._l10n_at_l16_bestand()])
deklaration.write({
'datei_l16': base64.b64encode(
datei.render()),
'datei_l16_name': 'l16_%d_%s.txt' % (
deklaration.jahr,
deklaration.company_id.l10n_at_beitragskontonummer
or deklaration.company_id.id),
})
bestand, ast_warn = \
deklaration._l10n_at_arbeitsstaette_bestand()
warnungen.extend(ast_warn)
if bestand is not None:
datei = L10nAtEldaDatei([bestand])
deklaration.write({
'datei_arbeitsstaette': base64.b64encode(
datei.render()),
'datei_arbeitsstaette_name':
'arbeitsstaette_%d.txt' % deklaration.jahr,
})
deklaration.protokoll = 'WARNUNGEN:\n%s' % (
'\n'.join(warnungen) or '')
deklaration.state = 'done'
return True
@@ -0,0 +1,96 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Meldewesen-Stammdaten (GP7/AP5): ELDA-/FinOnl-Identifikation der
Dienstgeberin und der Arbeitsstätten.
- ``res.company``: Beitragskontonummer (BKNR — ELDA-Feld, Präfix =
KV-Träger) und Lohnsteuernummer (9-stellig: 2 Stellen Finanzamt + 7
Stellen Nummer; L16-I1 STNRA/STNRL, Arbeitsstättenmeldung STNR).
- ``res.partner``: österreichische Gemeindekennziffer (AGKZ) für die
Arbeitsstättenmeldung nach § 34 Abs 6 ASVG — Odoo hält keine GKZ, das
Feld ist mandantenseitig zu pflegen (Warnung des L16-Laufs, wenn es am
Dienstort-Partner fehlt).
- ``hr.version``: Kennzeichen fallweise Beschäftigung (§ 33 Abs 1a
Z 1 ASVG — tagesgenaue vereinfachte Anmeldung vor Arbeitsantritt,
DM-ORG BEST MA) und VSNR-Formatprüfung (10-stellig LLLPTTMMJJ;
Prüfzifferalgorithmus der SVS ist nicht beschafft → nur Format/
Plausibilität, Rest dokumentiert).
- ``hr.departure.reason``: Mapping auf den eSV-Abmeldegrund (AGRD,
DM-ORG D.22; Standard ``00`` sonstiger Grund mit Ende des
Beschäftigungsverhältnisses)."""
from odoo import api, fields, models
from odoo.exceptions import ValidationError
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_at_beitragskontonummer = fields.Char(
string='AT Beitragskontonummer (BKNR)',
help='eSV-Beitragskontonummer des Dienstgebers (10 Stellen;\n'
'die ersten Stellen codieren den KV-Träger). Pflichtfeld\n'
'jeder ELDA-Meldung (mBGM-Paket BKNR, Versichertenmeldung,\n'
'Arbeitsstättenmeldung, fallweise Anmeldung).')
l10n_at_lohnsteuernummer = fields.Char(
string='AT Lohnsteuernummer',
help='9-stellig: 2 Stellen Finanzamtsnummer + 7 Stellen\n'
'Steuernummer (Lohnzettel Finanz I1 STNRA/STNRL;\n'
'Arbeitsstättenmeldung STNR).')
class ResPartner(models.Model):
_inherit = 'res.partner'
l10n_at_gemeindekennziffer = fields.Char(
string='AT Gemeindekennziffer (GKZ)',
help='5-stellige österreichische Gemeindekennzahl für die\n'
'Arbeitsstättenmeldung nach § 34 Abs 6 ASVG (Feld AGKZ).\n'
'Am Dienstort-Partner des Mitarbeiters pflegen.')
class HrVersion(models.Model):
_inherit = 'hr.version'
l10n_at_fallweise = fields.Boolean(
string='AT Fallweise Beschäftigung',
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help='Fallweise Beschäftigung (§ 33 Abs 1a Z 1 ASVG): je\n'
'Beschäftigungstag ist die vereinfachte Anmeldung vor\n'
'Arbeitsantritt zu übermitteln (ELDA BEST MA, Satzarten\n'
'32/33); An-/Abmeldung laufen über die mBGM (G3/G4).')
@api.constrains('ssnid')
def _l10n_at_vsnr_format(self):
"""VSNR-Formatprüfung LLLPTTMMJJ (10 Stellen, Ziffern, plausibles
Geburtsdatum TTMMJJ an den Stellen 510). Der
Prüfzifferalgorithmus der SVS ist nicht beschafft → nur
Format/Plausibilität (Rest dokumentiert; die Meldungsgeneratoren
listen ungültige VSNR listenweise als UserError)."""
for version in self:
if not version.ssnid:
continue
roh = version.ssnid.strip()
if len(roh) != 10 or not roh.isdigit():
raise ValidationError(
'Versicherungsnummer %r: 10 Ziffern im Format LLLPTTMMJJ '
'erwartet.' % version.ssnid)
try:
tag = int(roh[4:6])
monat = int(roh[6:8])
if not 1 <= tag <= 31 or not 1 <= monat <= 12:
raise ValueError()
except ValueError:
raise ValidationError(
'Versicherungsnummer %r enthält kein plausibles '
'Geburtsdatum (TTMMJJ an den Stellen 510).'
% version.ssnid)
class HrDepartureReason(models.Model):
_inherit = 'hr.departure.reason'
l10n_at_agrd = fields.Char(
string='AT eSV-Abmeldegrund (AGRD)',
help='2-stelliger Abmeldegrund-Code der Versichertenmeldung '
'(DM-ORG D.22); Standard 00 „Sonstiger Grund mit Ende des '
'Beschäftigungsverhältnisses“.')
@@ -0,0 +1,97 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""ELDA-Mitteilungsfile-Import (GP8): Rückantwort der Clearing-
stelle/Dateninhaltsprüfung als XML (`mitteilung_{Protokollnummer}.xml`).
XSDs lokal beschafft (`.firecrawl/ris/elda/Mitteilungsfiles_ZIP.zip`
— elda_mitteilung-1.2/2.1/3.0.xsd, nicht versioniert).
Der Import parst das XML, reichert die GP7-Deklarationsmodelle
(L16/mBGM) mit dem offiziellen Prüfergebnis an und liefert ein
lesbares Protokoll. Dateibasiert (Prämisse P7): der Mandant lädt
das Mitteilungsfile aus dem ELDA-Portal und lädt es hier hoch."""
import base64
import logging
import re
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class L10nAtPayrollMitteilungsfileWizard(models.TransientModel):
_name = 'l10n.at.payroll.mitteilungsfile.wizard'
_description = 'ELDA-Mitteilungsfile importieren (GP8)'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
datei = fields.Binary(
required=True, string='Mitteilungsfile (XML)',
help='ELDA-Rückantwort: mitteilung_{Protokollnummer}.xml '
'aus dem Empfangsverzeichnis des ELDA-Portals.')
datei_name = fields.Char()
protokoll = fields.Text(readonly=True)
def action_importieren(self):
"""XML parsen und Protokollzeilen extrahieren; die GP7-
Deklarationsmodelle werden über REFP/REFW-Matches verlinkt,
wo möglich. Das Mitteilungsfile 3.0 hat die Struktur:
<mitteilung> → <sendung> → <fehler>/<warnung> (je Satz-
art/REFW-Bezug)."""
for wizard in self:
if not wizard.datei:
raise UserError('Keine Datei hochgeladen.')
roh = base64.b64decode(wizard.datei)
try:
inhalt = roh.decode('utf-8')
except UnicodeDecodeError:
inhalt = roh.decode('iso-8859-15', errors='replace')
zeilen = wizard._parse_mitteilungsfile(inhalt)
wizard.protokoll = '\n'.join(zeilen) or 'Keine Fehler ' \
'oder Warnungen gefunden.'
return True
@api.model
def _parse_mitteilungsfile(self, inhalt):
"""Einfacher XML-Textparser: extrahiert <fehler>/<warnung>
Elemente mit Attributen und Textinhalt. Kein voller XSD-
Validator — die Fachprüfung bleibt der Clearingstelle; hier
wird das Ergebnis lesbar aufbereitet (Prämisse P7)."""
zeilen = []
# <fehler code="F0100" satzart="L1" refw="..."
# beschreibung="..."/> — XSD-abhängige Struktur.
for match in re.finditer(
r'<(?:fehler|warnung|error|warning)\b([^>]*?)/?>',
inhalt, re.IGNORECASE):
attrs = match.group(1)
art = 'Fehler' if 'fehler' in match.group(0).lower() \
else 'Warnung'
code = re.search(r'code="([^"]*)"', attrs)
satzart = re.search(r'satzart="([^"]*)"', attrs)
beschreibung = re.search(
r'(?:beschreibung|description|text)="([^"]*)"', attrs)
refw = re.search(
r'(?:refw|refn|referenz)="([^"]*)"', attrs)
zeile = art
if code:
zeile += ' %s' % code.group(1)
if satzart:
zeile += ' (Satzart %s)' % satzart.group(1)
if refw:
zeile += ' [Ref: %s]' % refw.group(1)
if beschreibung:
zeile += ': %s' % beschreibung.group(1)
if not code and not beschreibung:
zeile += ': %s' % match.group(0)[:120]
zeilen.append(zeile)
# Kopfprotokoll (Sendungsstatus).
for match in re.finditer(
r'<sendung\b([^>]*?)/?>', inhalt, re.IGNORECASE):
attrs = match.group(1)
status = re.search(r'status="([^"]*)"', attrs)
if status:
zeilen.insert(0, 'Sendungsstatus: %s' % status.group(1))
if not zeilen:
zeilen.append('Mitteilungsfile gelesen — keine '
'<fehler>/<warnung>-Elemente gefunden.')
return zeilen
@@ -0,0 +1,90 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Monatsdienstliste und Monatsjournal (GP7/AP6): QWeb-Datenlieferanten
auf dem Lohnlauf (hr.payslip.run) — voraberechnete Aggregation über
`hr.payslip.line` (read_group-Muster des Contribution Register), damit
die Templates reine Darstellung bleiben.
- **Monatsdienstliste**: je AN eine Zeile mit den mBGM-Kontrollgrößen
(Versicherungstage/Beitragsgrundlagen je Beitragsmonat, DN- und
DG-Beiträge, DG-Anteile) — Abstimmung Abrechnung ↔ SV-Meldung.
- **Monatsjournal**: je Lohnart die Summe über den Lauf (Brutto,
Abzüge, DG-Anteile, Netto) mit Kontrollsummen gegen die
GP6-Buchung."""
from odoo import models
class L10nAtPayrollMonatsReports(models.AbstractModel):
_name = 'report.l10n_at_hr_payroll.report_monatsdienstliste'
_description = 'Monatsdienstliste (GP7/AP6)'
def _get_report_values(self, docids, data=None):
runs = self.env['hr.payslip.run'].browse(docids)
zeilen = []
for run in runs:
for slip in run.slip_ids.filtered(
lambda p: p.state not in ('draft', 'cancel')):
version = slip.version_id
basen = slip._l10n_at_sv_basis_laufen_monate()
sv_dn = sum(
slip._l10n_at_svdn(k) for k in
('kv', 'pv', 'av', 'ak', 'wf'))
sv_dg = slip._l10n_at_svdg_gesamt()
sz_dn = slip._l10n_at_svdn_sz()
sz_basis = slip._l10n_at_svdn_basis_sz()
zeilen.append({
'slip': slip,
'versicherungsnummer': version.ssnid if version else '',
'personalgruppe': getattr(
version, 'l10n_at_personalgruppe', '')
if version else '',
'geringfuegig': slip.l10n_at_geringfuegig,
'basis_laufen': sum(
b for _m, b in basen),
'sz_basis': sz_basis,
'sv_dn': sv_dn,
'sv_dg': sv_dg,
'sv_dn_sz': sz_dn,
'lohnsteuer': sum(
slip.line_ids.filtered(
lambda l: l.code in ('LST_LFD', 'LST_SZ')
).mapped('total')),
'netto': sum(
slip.line_ids.filtered(
lambda l: l.code == 'NET').mapped('total')),
'brutto': sum(
slip.line_ids.filtered(
lambda l: l.salary_rule_id.l10n_at_bemessung
!= 'neutral').mapped('total')),
})
return {'docs': runs, 'zeilen': zeilen}
class L10nAtPayrollMonatsjournal(models.AbstractModel):
_name = 'report.l10n_at_hr_payroll.report_monatsjournal'
_description = 'Monatsjournal (GP7/AP6)'
def _get_report_values(self, docids, data=None):
runs = self.env['hr.payslip.run'].browse(docids)
zeilen = []
for run in runs:
gruppen = {}
for slip in run.slip_ids.filtered(
lambda p: p.state not in ('draft', 'cancel')):
for line in slip.line_ids:
regel = line.salary_rule_id
if not line.total or regel.l10n_at_bemessung \
== 'neutral':
continue
schluessel = (regel.code, regel.name,
regel.l10n_at_bemessung)
eintrag = gruppen.setdefault(schluessel, 0.0)
gruppen[schluessel] = eintrag + line.total
zeilen.append({
'run': run,
'arten': [
{'code': code, 'name': name, 'bemessung': bemessung,
'summe': summe}
for (code, name, bemessung), summe
in sorted(gruppen.items())],
})
return {'docs': runs, 'journal': zeilen}
@@ -0,0 +1,203 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP7/AP6 Belege: Lohnzettel AT (am Lohnzettel/Payslip), -->
<!-- Monatsdienstliste + Monatsjournal (am Lohnlauf). -->
<!-- ============================================================ -->
<record id="action_report_lohnzettel_at" model="ir.actions.report">
<field name="name">Lohnzettel (AT)</field>
<field name="model">hr.payslip</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll.report_lohnzettel_at</field>
<field name="report_file">l10n_at_hr_payroll.report_lohnzettel_at</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_monatsdienstliste" model="ir.actions.report">
<field name="name">Monatsdienstliste (SV-Kontrolle)</field>
<field name="model">hr.payslip.run</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll.report_monatsdienstliste</field>
<field name="report_file">l10n_at_hr_payroll.report_monatsdienstliste</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_monatsjournal" model="ir.actions.report">
<field name="name">Monatsjournal</field>
<field name="model">hr.payslip.run</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll.report_monatsjournal</field>
<field name="report_file">l10n_at_hr_payroll.report_monatsjournal</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
<field name="binding_type">report</field>
</record>
<!-- Lohnzettel AT: AT-Layout — Lohnarten nach Kategorie, DN-Abzüge,
DG-Anteile (SV-DG, FLAF, KommSt, DZ, BV), Netto. -->
<template id="report_lohnzettel_at">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="slip">
<t t-call="web.external_layout">
<div class="page">
<h2>Lohnzettel</h2>
<table class="table table-sm">
<tr>
<td><strong>Mitarbeiter:in</strong></td>
<td><t t-out="slip.employee_id.name"/></td>
<td><strong>Versicherungsnummer</strong></td>
<td><t t-out="slip.version_id.ssnid or ''"/></td>
</tr>
<tr>
<td><strong>Abrechnungsperiode</strong></td>
<td><t t-out="slip.date_from"/> <t t-out="slip.date_to"/></td>
<td><strong>Personalgruppe</strong></td>
<td><t t-out="slip.version_id.l10n_at_personalgruppe or ''"/></td>
</tr>
</table>
<h3>Bezüge</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Lohnart</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Brutto</strong></td>
<td class="text-end">
<strong><span t-out="sum(slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO').mapped('total'))" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/></strong>
</td>
</tr>
</tbody>
</table>
<h3>Abzüge (Dienstnehmer:in)</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Abzug</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.category_id.code == 'ATP_ABZUG')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="-line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<h3>Dienstgeberanteile</h3>
<table class="table table-sm">
<thead>
<tr>
<th>DG-Komponente</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.category_id.code == 'ATP_DG')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<p class="text-end h4">
<strong>Netto:</strong>
<span t-out="sum(slip.line_ids.filtered(lambda l: l.code == 'NET').mapped('total'))" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</p>
</div>
</t>
</t>
</t>
</template>
<!-- Monatsdienstliste: je AN die mBGM-Kontrollgrößen -->
<template id="report_monatsdienstliste">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="run">
<t t-call="web.external_layout">
<div class="page">
<h2>Monatsdienstliste — <t t-out="run.name"/></h2>
<table class="table table-sm">
<thead>
<tr>
<th>Mitarbeiter:in</th>
<th>VSNR</th>
<th>Gruppe</th>
<th class="text-end">BG laufend</th>
<th class="text-end">BG SZ</th>
<th class="text-end">SV-DN</th>
<th class="text-end">SV-DN SZ</th>
<th class="text-end">SV-DG</th>
<th class="text-end">Lohnsteuer</th>
<th class="text-end">Netto</th>
</tr>
</thead>
<tbody>
<tr t-foreach="zeilen" t-as="z">
<td><t t-out="z['slip'].employee_id.name"/></td>
<td><t t-out="z['versicherungsnummer']"/></td>
<td><t t-out="z['personalgruppe']"/><t t-if="z['geringfuegig']" t-out=" (geringf.)"/></td>
<td class="text-end"><span t-out="z['basis_laufen']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sz_basis']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sv_dn']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sv_dn_sz']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['sv_dg']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['lohnsteuer']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
<td class="text-end"><span t-out="z['netto']" t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
<!-- Monatsjournal: je Lohnart die Summe über den Lauf -->
<template id="report_monatsjournal">
<t t-call="web.html_container">
<t t-foreach="journal" t-as="j">
<t t-call="web.external_layout">
<div class="page">
<h2>Monatsjournal — <t t-out="j['run'].name"/></h2>
<table class="table table-sm">
<thead>
<tr>
<th>Code</th>
<th>Lohnart</th>
<th>Bemessung</th>
<th class="text-end">Summe</th>
</tr>
</thead>
<tbody>
<tr t-foreach="j['arten']" t-as="art">
<td><t t-out="art['code']"/></td>
<td><t t-out="art['name']"/></td>
<td><t t-out="art['bemessung']"/></td>
<td class="text-end"><span t-out="art['summe']" t-options="{&quot;widget&quot;: &quot;monetary&quot;, &quot;display_currency&quot;: j['run'].company_id.currency_id}"/></td>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>
@@ -2,4 +2,14 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_l10n_at_payroll_tasy_mapping_user,L10n AT Payroll TASY-Mapping User,model_l10n_at_payroll_tasy_mapping,hr_payroll.group_hr_payroll_user,1,0,0,0
access_l10n_at_payroll_tasy_mapping_manager,L10n AT Payroll TASY-Mapping Manager,model_l10n_at_payroll_tasy_mapping,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_tasy_import_wizard,L10n AT Payroll TASY-Import Wizard,model_l10n_at_payroll_tasy_import_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_tasy_import_line,L10n AT Payroll TASY-Import Line,model_l10n_at_payroll_tasy_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_tasy_import_line,L10n AT Payroll TASY-Import Line,model_l10n_at_payroll_tasy_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_l16_user,L10n AT Payroll L16 User,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_l16_manager,L10n AT Payroll L16 Manager,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_l16_line_user,L10n AT Payroll L16-Line User,model_l10n_at_payroll_l16_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_kind_user,L10n AT Payroll Kind User,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_kind_manager,L10n AT Payroll Kind Manager,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_frist_user,L10n AT Payroll Frist User,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_frist_manager,L10n AT Payroll Frist Manager,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_jahresretro_wizard,L10n AT Payroll Jahresretro Wizard,model_l10n_at_payroll_jahresretro_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_jahresretro_ergebnis,L10n AT Payroll Jahresretro Ergebnis,model_l10n_at_payroll_jahresretro_ergebnis,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_mitteilungsfile_wizard,L10n AT Payroll Mitteilungsfile Wizard,model_l10n_at_payroll_mitteilungsfile_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_l10n_at_payroll_tasy_mapping_user L10n AT Payroll TASY-Mapping User model_l10n_at_payroll_tasy_mapping hr_payroll.group_hr_payroll_user 1 0 0 0
3 access_l10n_at_payroll_tasy_mapping_manager L10n AT Payroll TASY-Mapping Manager model_l10n_at_payroll_tasy_mapping hr_payroll.group_hr_payroll_manager 1 1 1 1
4 access_l10n_at_payroll_tasy_import_wizard L10n AT Payroll TASY-Import Wizard model_l10n_at_payroll_tasy_import_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
5 access_l10n_at_payroll_tasy_import_line L10n AT Payroll TASY-Import Line model_l10n_at_payroll_tasy_import_line hr_payroll.group_hr_payroll_user 1 1 1 1
6 access_l10n_at_payroll_l16_user L10n AT Payroll L16 User model_l10n_at_payroll_l16 hr_payroll.group_hr_payroll_user 1 1 1 0
7 access_l10n_at_payroll_l16_manager L10n AT Payroll L16 Manager model_l10n_at_payroll_l16 hr_payroll.group_hr_payroll_manager 1 1 1 1
8 access_l10n_at_payroll_l16_line_user L10n AT Payroll L16-Line User model_l10n_at_payroll_l16_line hr_payroll.group_hr_payroll_user 1 1 1 1
9 access_l10n_at_payroll_kind_user L10n AT Payroll Kind User model_l10n_at_payroll_kind hr_payroll.group_hr_payroll_user 1 1 1 1
10 access_l10n_at_payroll_kind_manager L10n AT Payroll Kind Manager model_l10n_at_payroll_kind hr_payroll.group_hr_payroll_manager 1 1 1 1
11 access_l10n_at_payroll_frist_user L10n AT Payroll Frist User model_l10n_at_payroll_frist hr_payroll.group_hr_payroll_user 1 1 1 1
12 access_l10n_at_payroll_frist_manager L10n AT Payroll Frist Manager model_l10n_at_payroll_frist hr_payroll.group_hr_payroll_manager 1 1 1 1
13 access_l10n_at_payroll_jahresretro_wizard L10n AT Payroll Jahresretro Wizard model_l10n_at_payroll_jahresretro_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
14 access_l10n_at_payroll_jahresretro_ergebnis L10n AT Payroll Jahresretro Ergebnis model_l10n_at_payroll_jahresretro_ergebnis hr_payroll.group_hr_payroll_user 1 1 1 1
15 access_l10n_at_payroll_mitteilungsfile_wizard L10n AT Payroll Mitteilungsfile Wizard model_l10n_at_payroll_mitteilungsfile_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
+3 -1
View File
@@ -1,4 +1,6 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import test_tasy_import
from . import test_lohnsteuer
from . import test_sozialversicherung
from . import test_sozialversicherung
from . import test_elda_l16
from . import test_gp8
@@ -0,0 +1,239 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP7 Kern: ELDA-Fixsatz-Framework (DM-ORG 42.7.0 Konventionen) und
Lohnzettel-Finanz-Generator (I1/L1, BEST LF/94) — Positionsexakte
Satz-Assertions, CRLF/ISO-8859-15-Encoding, SANR/SANZ-Kette,
KZ-245-Formel-Konsistenz aus GP3-Daten."""
import base64
import datetime
from odoo.tests import TransactionCase, tagged
from odoo.addons.l10n_at_hr_payroll.models.elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
l10n_at_euro_cent,
)
@tagged('post_install', '-at_install')
class EldaFrameworkTest(TransactionCase):
def test_01_feldformatierung(self):
"""a/n: linksbündig blank-gefüllt; n: rechtsbündig mit führenden
Nullen, keine Interpunktion; Cent kaufmännisch; skala=3 für
Prozentsätze (39,30 % → 039300)."""
satz = L10nAtEldaSatz(
[(1, 'A', 1, 5, 'a'), (2, 'N', 6, 4, 'n'),
(3, 'B', 10, 10, 'n')], 20)
satz.setze('A', 'AB')
satz.setze('N', 42)
self.assertEqual(satz.render()[:5], 'AB ')
self.assertEqual(satz.render()[5:9], '0042')
# Unbelegtes n-Feld: Grundstellung 0.
self.assertEqual(satz.render()[9:19], '0' * 10)
self.assertEqual(l10n_at_euro_cent(1234.567), 123457)
self.assertEqual(l10n_at_euro_cent(0.005), 1)
prozent = L10nAtEldaSatz(
[(1, 'VPTA', 1, 6, 'n')], 6)
prozent.setze('VPTA', 39.30, skala=3)
self.assertEqual(prozent.render(), '039300')
def test_02_bestand_datei(self):
"""Vorlaufsatz SART 00 mit BEST/VSTR/VERS, SANR lückenlos ab 1,
SANZ inklusive Vorlauf/Schluss, CRLF-getrennte Zeilen in
ISO-8859-15, optionaler Dateiheader ENCD."""
satz1 = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + [(2, 'X', 21, 4, 'a')], 24,
bezeichnung='Test')
satz1.setze('SART', '45')
satz1.setze('VSTR', 'ST')
satz1.setze('X', 'TEST')
satz2 = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + [(2, 'Y', 21, 4, 'n')], 24,
bezeichnung='Test')
satz2.setze('SART', '45')
satz2.setze('VSTR', 'ST')
satz2.setze('Y', 7)
bestand = L10nAtEldaBestand(
'AD', 'ST', '02',
hersteller={'name': 'Test', 'kfz': 'A'},
datensaetze=[satz1, satz2])
zeilen = bestand.render(
erstellungsdatum=datetime.date(2026, 1, 15))
vorlauf = zeilen[0]
self.assertEqual(len(zeilen), 4)
self.assertEqual(vorlauf[18:20], 'ST') # VSTR (Pos. 19)
self.assertEqual(vorlauf[22:24], 'AD') # BEST
self.assertEqual(vorlauf[30:38], '15012026') # EDAT TTMMJJJJ
self.assertEqual(vorlauf[149:151], '02') # VERS
self.assertEqual(zeilen[1][0:2], '45')
self.assertEqual(zeilen[1][2:9], '0000002') # SANR
self.assertEqual(zeilen[3][20:26], '000004') # SANZ
datei = L10nAtEldaDatei([bestand])
roh = datei.render()
self.assertIn(b'\r\n', roh)
erste = roh.split(b'\r\n')[0]
self.assertEqual(erste[:4], b' 01')
self.assertIn(b'ISO-8859-15', erste)
# Umlaut in ISO-8859-15 codiert.
satz3 = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + [(2, 'Z', 21, 10, 'a')], 30,
bezeichnung='Umlaut')
satz3.setze('SART', '45')
satz3.setze('VSTR', 'ST')
satz3.setze('Z', 'Für')
self.assertIn('Für'.encode('ISO-8859-15'),
satz3.render().encode('ISO-8859-15'))
@tagged('post_install', '-at_install')
class L16GeneratorTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_lohnsteuernummer': '123456789',
'l10n_at_beitragskontonummer': '1412345678',
})
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT Test 40h',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 16.0})
for wd in range(5)
],
})
self.company.resource_calendar_id = self.calendar
employee = self.env['hr.employee'].create({
'name': 'L16 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': 'female',
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
self.employee = employee
self.version = version
def _slip(self, monat, jahr=2026, 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': 'L16 %d/%d' % (monat, jahr),
'employee_id': self.employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
if confirm:
slip.action_payslip_done()
return slip
def test_01_l16_lauf_und_positionen(self):
"""Jahreslauf: KZ-Salden aus GP3/GP4-Flags, L1-Record mit
exakten Positionen (Satzlänge 3500, REFN/ARTL/KZ-V/B-Paare,
SOZS/AVLN/AGBD), I1 (1100, STNRA/JAHR/GESA/ANZA), Datei mit
LF-Bestand VSTR 94, KZ-245-Konsistenz per Konstruktion."""
self._slip(11) # November mit B13
deklaration = self.env['l10n.at.payroll.l16'].create({
'company_id': self.company.id,
'jahr': 2026,
'art': '01',
})
deklaration.action_erzeugen()
self.assertEqual(deklaration.state, 'done')
self.assertEqual(len(deklaration.line_ids), 1)
line = deklaration.line_ids
# November: KZ 210 = KVGEO + B13 = 4.118,82 (§ 25 gesamt);
# KZ 220 = B13 = 2.059,41; KZ 245 = laufende Bemessung.
self.assertAlmostEqual(line.kz210, 4118.82, places=2)
self.assertAlmostEqual(line.kz220, 2059.41, places=2)
# KZ-245-Formel: 210 215 220 230 243
erwartet = (line.kz210 - line.kz215 - line.kz220
- line.kz230 - line.kz243)
self.assertAlmostEqual(line.kz245, erwartet, places=1)
# Datei: I1 (1100) + L1 (3500) + Vorlauf/Schluss (auf 3500
# aufgefüllt), LF-Bestand, VSTR 94, Lohnzettelversion 28.
roh = base64.b64decode(deklaration.datei_l16).decode(
'ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
# Dateiheader + Vorlauf + I1 + L1 + Schluss.
self.assertEqual(len(zeilen), 5)
header, vorlauf, i1, l1, schluss = zeilen
self.assertEqual(header[:4], ' 01')
self.assertIn('ISO-8859-15', header)
self.assertEqual(len(i1), 1100)
self.assertEqual(len(l1), 3500)
self.assertEqual(vorlauf[0:2], '00')
self.assertEqual(vorlauf[18:20], '94')
self.assertEqual(vorlauf[22:24], 'LF')
self.assertEqual(vorlauf[149:151], '28')
self.assertEqual(i1[18:20], '94')
self.assertEqual(i1[20:21], 'I')
self.assertEqual(i1[34:43], '123456789') # STNRL
self.assertEqual(i1[50:59], '123456789') # STNRA
self.assertEqual(i1[82:84], '03') # STVE
self.assertEqual(i1[232:236], '2026') # JAHR
self.assertEqual(i1[236:243], '0000001') # GESA
self.assertEqual(l1[18:20], '94')
self.assertEqual(l1[20:21], 'L')
self.assertEqual(l1[95:97], '01') # ARTL
self.assertEqual(l1[119:123], '0111') # BELZ TTMM
self.assertEqual(l1[123:127], '3011') # ENLZ
self.assertEqual(l1[127:131], '2026') # JALZ
self.assertEqual(l1[131:132], '3') # SOZS Angestellte
self.assertEqual(l1[132:136], '1234') # AVLN LLLP
self.assertEqual(l1[136:142], '150590') # AGBD TTMMJJ
# KZ-Felder: V/B-Paare (1-based-Positionen → Index pos1).
self.assertEqual(l1[296:297], '+') # V210 (Pos. 297)
self.assertEqual(l1[297:307], '0000411882') # B210
self.assertEqual(l1[318:319], '+') # V220 (Pos. 319)
self.assertEqual(l1[319:329], '0000205941') # B220
self.assertEqual(l1[450:451], '+') # V245 (Pos. 451)
b245 = '%010d' % int(round(line.kz245 * 100))
self.assertEqual(l1[451:461], b245)
self.assertEqual(schluss[0:2], '99')
self.assertEqual(schluss[20:26], '000004') # SANZ
def test_02_arbeitsstaette(self):
"""§ 34 Abs 6 ASVG: je AN ein Satz 45 (Länge 300) mit BKNR,
VSNR, STNR und GKZ aus dem Dienstort-Partner; Warnung, wenn
die GKZ fehlt."""
self._slip(1)
deklaration = self.env['l10n.at.payroll.l16'].create({
'company_id': self.company.id,
'jahr': 2026,
'art': '01',
})
deklaration.action_erzeugen()
self.assertTrue(deklaration.datei_arbeitsstaette)
roh = base64.b64decode(
deklaration.datei_arbeitsstaette).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
vorlauf, satz45 = zeilen[1], zeilen[2]
self.assertEqual(vorlauf[18:20], 'ST')
self.assertEqual(vorlauf[22:24], 'AD')
self.assertEqual(satz45[0:2], '45')
self.assertEqual(len(satz45), 300)
self.assertEqual(satz45[22:32], '1412345678') # BKNR
self.assertEqual(satz45[32:42], '1234150590') # VSNR
self.assertEqual(satz45[52:61], '123456789') # STNR
self.assertIn('Gemeindekennziffer', deklaration.protokoll)
+107
View File
@@ -0,0 +1,107 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP8 Kern: Fristenkalender (Auto-Erzeugung, Frist-Typen, Überfällig-
Logik), Jahres-Retro (Kontrollsechstel-Prüfung), Mitteilungsfile-Parser."""
from datetime import date
from odoo.tests import TransactionCase, tagged
from odoo.addons.l10n_at_hr_payroll.models.fristenkalender import (
FRISTART_DEFINITIONEN,
)
@tagged('post_install', '-at_install')
class FristenTest(TransactionCase):
def test_01_frist_definitionen(self):
"""Alle Fristarten sind definiert mit Name/Deadline-Logik."""
for art, definition in FRISTART_DEFINITIONEN.items():
self.assertIn('name', definition)
self.assertIn('hilfetext', definition)
self.assertTrue(
'monatlich' in definition or 'monat' in definition,
'Fristart %s ohne Fälligkeits-Logik' % art)
def test_02_cron_erzeugt_fristen(self):
"""Cron erzeugt monatliche SV/DB-Fristen idempotent + jährliche
Fristen am 1. Jänner."""
frist = self.env['l10n.at.payroll.frist']
company = self.env.company
# Manueller Cron-Lauf (heute ist ein Tag im Jahr).
frist._cron_fristen_erzeugen()
# Monatliche Fristen existieren für den Vormonat.
heute = date.today()
vormonat = (heute.replace(day=1) - __import__('datetime')
.timedelta(days=1))
monatliche = frist.search([
('company_id', '=', company.id),
('fristart', 'in', ('sv_mbgm', 'db_dz_kommst')),
('jahr', '=', vormonat.year),
('monat', '=', vormonat.month)])
self.assertEqual(len(monatliche), 2)
# Idempotenz: zweiter Lauf erzeugt keine Duplikate.
frist._cron_fristen_erzeugen()
monatliche2 = frist.search([
('company_id', '=', company.id),
('fristart', 'in', ('sv_mbgm', 'db_dz_kommst')),
('jahr', '=', vormonat.year),
('monat', '=', vormonat.month)])
self.assertEqual(len(monatliche2), 2)
def test_03_state_ueberfaellig(self):
"""Überfällige Fristen markieren sich nach Deadline-
Überschreitung; action_erledigt setzt erledigt."""
frist = self.env['l10n.at.payroll.frist'].create({
'company_id': self.env.company.id,
'fristart': 'l16',
'jahr': 2025,
'monat': 0,
'deadline': '2026-01-01',
})
self.assertEqual(frist.state, 'ueberfaellig')
frist.action_erledigt()
self.assertEqual(frist.state, 'erledigt')
self.assertTrue(frist.erledigt_am)
@tagged('post_install', '-at_install')
class MitteilungsfileTest(TransactionCase):
def test_01_parse_mitteilungsfile(self):
"""XML-Parser extrahiert Fehler-/Warnung-Elemente."""
wizard = self.env['l10n.at.payroll.mitteilungsfile.wizard']
xml = """<?xml version="1.0"?>
<mitteilung>
<sendung status="ok"/>
<fehler code="F0100" satzart="L1"
beschreibung="STEUERNUMMER fehlt"/>
<warnung code="W0102" satzart="G1"
beschreibung="Beitragssatz-Differenz"/>
</mitteilung>"""
zeilen = wizard._parse_mitteilungsfile(xml)
self.assertIn('Sendungsstatus: ok', zeilen)
self.assertTrue(any('F0100' in z for z in zeilen))
self.assertTrue(any('W0102' in z for z in zeilen))
self.assertTrue(any('STEUERNUMMER' in z for z in zeilen))
def test_02_import_ohne_datei(self):
"""Import ohne Datei löst UserError."""
from odoo.exceptions import UserError
wizard = self.env['l10n.at.payroll.mitteilungsfile.wizard'] \
.create({'company_id': self.env.company.id})
with self.assertRaises(UserError):
wizard.action_importieren()
@tagged('post_install', '-at_install')
class JahresretroTest(TransactionCase):
def test_01_wizard_ohne_daten(self):
"""Wizard ohne Läufe im Jahr: leeres Ergebnis, keine Fehler."""
wizard = self.env['l10n.at.payroll.jahresretro.wizard'].create({
'company_id': self.env.company.id,
'jahr': 1999, # weit in der Vergangenheit
})
wizard.action_pruefen()
self.assertFalse(wizard.ergebnis_ids)
self.assertIn('1999', wizard.zusammenfassung)
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP8: Fristenkalender + Jahres-Retro + Mitteilungsfile-Import. -->
<!-- ============================================================ -->
<record id="frist_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.frist.list</field>
<field name="model">l10n.at.payroll.frist</field>
<field name="arch" type="xml">
<list string="Fristenkalender" default_order="deadline">
<field name="deadline"/>
<field name="fristart"/>
<field name="jahr"/>
<field name="monat" optional="hide"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'erledigt'"
decoration-warning="state == 'offen'"
decoration-danger="state == 'ueberfaellig'"/>
<button name="action_erledigt" type="object"
icon="fa-check" string="Erledigt"
invisible="state == 'erledigt'"/>
<button name="action_zum_objekt" type="object"
icon="fa-arrow-right" string="Zur Meldung"/>
</list>
</field>
</record>
<record id="frist_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.frist.form</field>
<field name="model">l10n.at.payroll.frist</field>
<field name="arch" type="xml">
<form string="Frist">
<sheet>
<group>
<group>
<field name="fristart"/>
<field name="jahr"/>
<field name="monat"/>
<field name="deadline"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="state" widget="badge"/>
<field name="erledigt_am"/>
</group>
</group>
<notebook>
<page string="Hinweis" name="hinweis">
<field name="hilfetext"/>
</page>
<page string="Meldungsobjekt" name="objekt">
<group>
<field name="ref_l16_id"/>
</group>
</page>
</notebook>
</sheet>
<header>
<button name="action_erledigt" type="object"
string="Als erledigt markieren"
invisible="state == 'erledigt'"/>
<button name="action_wieder_eroeffnen" type="object"
string="Wieder eröffnen"
invisible="state != 'erledigt'"/>
</header>
</form>
</field>
</record>
<record id="action_frist" model="ir.actions.act_window">
<field name="name">Fristenkalender</field>
<field name="res_model">l10n.at.payroll.frist</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_state': 'offen'}</field>
</record>
<!-- Jahres-Retro-Wizard -->
<record id="jahresretro_wizard_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.jahresretro.wizard.form</field>
<field name="model">l10n.at.payroll.jahresretro.wizard</field>
<field name="arch" type="xml">
<form string="Jahres-Retro-/Aufrollungslauf">
<group>
<group>
<field name="jahr"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
<group>
<field name="nur_kontrollsechstel"/>
<field name="aufrollung_77"/>
<field name="zeige_detail"/>
</group>
</group>
<footer>
<button name="action_pruefen" type="object"
string="Jahresdaten prüfen" class="btn-primary"/>
<button string="Schließen" class="btn-secondary"
special="cancel"/>
</footer>
<notebook invisible="not zeige_detail">
<page string="Prüfergebnis" name="ergebnis">
<field name="ergebnis_ids">
<list create="0" delete="0">
<field name="employee_id"/>
<field name="kontrollsechstel_ueberhang"/>
<field name="fixsatz_steuer"/>
<field name="tarif_steuer"/>
<field name="guenstig" widget="badge"
decoration-success="guenstig"
decoration-danger="not guenstig"/>
<field name="handlung"/>
</list>
</field>
</page>
<page string="Zusammenfassung" name="zusammenfassung">
<field name="zusammenfassung"/>
</page>
</notebook>
</form>
</field>
</record>
<record id="action_jahresretro_wizard" model="ir.actions.act_window">
<field name="name">Jahres-Retro-/Aufrollungslauf</field>
<field name="res_model">l10n.at.payroll.jahresretro.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Mitteilungsfile-Import -->
<record id="mitteilungsfile_wizard_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mitteilungsfile.wizard.form</field>
<field name="model">l10n.at.payroll.mitteilungsfile.wizard</field>
<field name="arch" type="xml">
<form string="ELDA-Mitteilungsfile importieren">
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="datei" filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
<footer>
<button name="action_importieren" type="object"
string="Importieren" class="btn-primary"/>
<button string="Schließen" class="btn-secondary"
special="cancel"/>
</footer>
<field name="protokoll" invisible="not protokoll"/>
</form>
</field>
</record>
<record id="action_mitteilungsfile_wizard" model="ir.actions.act_window">
<field name="name">ELDA-Mitteilungsfile importieren</field>
<field name="res_model">l10n.at.payroll.mitteilungsfile.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Cron: Fristen monatlich/jährlich erzeugen -->
<record id="ir_cron_fristen_erzeugen" model="ir.cron">
<field name="name">L10n AT Payroll: Fristenkalender pflegen</field>
<field name="model_id" ref="model_l10n_at_payroll_frist"/>
<field name="state">code</field>
<field name="code">model._cron_fristen_erzeugen()</field>
<field name="interval_number">1</field>
<field name="interval_type">months</field>
<field name="nextcall">2026-10-01 02:00:00</field>
<field name="active" eval="True"/>
</record>
<!-- Menüs -->
<menuitem id="menu_l10n_at_payroll_fristen"
name="Fristenkalender"
parent="menu_l10n_at_payroll_meldewesen"
action="action_frist" sequence="5"/>
<menuitem id="menu_l10n_at_payroll_jahresretro"
name="Jahres-Retro-/Aufrollungslauf"
parent="menu_l10n_at_payroll_meldewesen"
action="action_jahresretro_wizard" sequence="20"/>
<menuitem id="menu_l10n_at_payroll_mitteilungsfile"
name="Mitteilungsfile-Import"
parent="menu_l10n_at_payroll_meldewesen"
action="action_mitteilungsfile_wizard" sequence="30"/>
</odoo>
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP7 Meldewesen: L16-/Arbeitsstätten-Deklaration + Kind-Stamm -->
<!-- (FB+-Wartung am Mitarbeiter). -->
<!-- ============================================================ -->
<record id="l16_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.l16.list</field>
<field name="model">l10n.at.payroll.l16</field>
<field name="arch" type="xml">
<list string="Lohnzettel Finanz (L16)">
<field name="jahr"/>
<field name="art"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="l16_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.l16.form</field>
<field name="model">l10n.at.payroll.l16</field>
<field name="arch" type="xml">
<form string="Lohnzettel Finanz (L16) &amp; Arbeitsstättenmeldung">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (Dateien)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="jahr"/>
<field name="art"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Mitteilungssätze" name="linien">
<field name="line_ids">
<list editable="bottom">
<field name="employee_name"/>
<field name="versicherungsnummer"/>
<field name="soziale_stellung"/>
<field name="kz210" optional="show"/>
<field name="kz215" optional="hide"/>
<field name="kz220" optional="show"/>
<field name="kz230" optional="hide"/>
<field name="kz245" optional="show"/>
<field name="lohnsteuer"/>
<field name="fab_betrag"
string="FB+"/>
</list>
</field>
</page>
<page string="Dateien" name="dateien">
<group>
<field name="datei_l16"
filename="datei_l16_name"/>
<field name="datei_l16_name" invisible="1"/>
<field name="datei_arbeitsstaette"
filename="datei_arbeitsstaette_name"/>
<field name="datei_arbeitsstaette_name"
invisible="1"/>
</group>
</page>
<page string="Prüfprotokoll" name="protokoll">
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_l16" model="ir.actions.act_window">
<field name="name">Lohnzettel Finanz (L16)</field>
<field name="res_model">l10n.at.payroll.l16</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_l10n_at_payroll_meldewesen"
name="Meldewesen (ELDA/FinOnl)"
parent="hr_work_entry_enterprise.menu_hr_payroll_root"
sequence="97"/>
<menuitem id="menu_l10n_at_payroll_l16"
name="Lohnzettel Finanz (L16)"
parent="menu_l10n_at_payroll_meldewesen"
action="action_l16" sequence="10"/>
<!-- Kind-Stammdaten (FB+-Wartung) als Seite am Mitarbeiterformular -->
<record id="hr_employee_view_form_kind" model="ir.ui.view">
<field name="name">hr.employee.form.l10n_at_payroll_kind</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<notebook position="inside">
<page string="Kinder (FB+)" name="l10n_at_payroll_kinder"
groups="hr_payroll.group_hr_payroll_user">
<field name="l10n_at_payroll_kind_ids">
<list editable="bottom">
<field name="familienname"/>
<field name="vorname"/>
<field name="geburtstag"/>
<field name="versicherungsnummer"/>
<field name="fab_plus" widget="boolean_toggle"/>
<field name="fab_monat_von"/>
<field name="fab_monat_bis"/>
<field name="fab_halb_monat_von"/>
<field name="fab_halb_monat_bis"/>
</list>
</field>
</page>
</notebook>
</field>
</record>
</odoo>
+27 -9
View File
@@ -117,19 +117,37 @@ Auszahlung über `hr_payroll_account_iso20022`; kein VRV/MVAG
Rechenfälle `docs/rechenfaelle/buchhaltung.md`.
**Offen (GP7+):** Meldewesen/Belege (GP7), Jahreswechsel (GP8);
All-in-UI-Bericht (GP8); B14 längster-Urlaubs-Teil-Verfeinerung;
DZ-WKO-Verifikation (8.11) vor Wien-Mandanten; §-7-BMSVG-Fiktivfälle
(Präsenzdienst/Kranken-/Wochengeld) als dokumentierte Option
(Rechenfälle-Datei); Arbeiter-Sub-Konten (Option A-Rest) mit der
Referenzbuchhaltung fixieren (GP0-Rest).
**GP7 Meldewesen/Belege (umgesetzt 2026-09-12):** auf dem Kern-
Framework (ELDA-Fixlängen-Dateien, ISO-8859-15, CRLF-Annahme;
L16 + Arbeitsstättenmeldung + Belege Lohnzettel AT/Monatsdienstliste/
Monatsjournal im Kern): **mBGM/JASV** (Paket/mBGM/Tarifblock/Verrech-
nungsbasis/-position, T01-Gesamtsatz aus ``at_mbgm_tarif`` — normativ
39,05 % laufend / 37,55 % SZ; Abschläge A01/A02/A03 § 2a AMPFG **je
Basis AB und SZ**, A09/A10/A15 aus GP4-Stichtagen; BV-Basis + V01 aus
GP5; Storno-R-Sätze mit REFU; Fehlanzeige als Null-Paket; Gering-
fährige/fallweise ohne Tarifparameter = listenweiser Laufabbruch);
**Versichertenmeldung** M3/M4 (deklarative Event-Erkennung über die
Meldungs-Registry — idempotent, kein write-diff; BBER 0104, GERF,
FRDV, BVAB/BVJN aus GP5, VWAZ; AGRD-Mapping am Abgangsgrund) und
**fallweise Anmeldung** (BEST MA, SART 32; MVP je Anmeldetag);
NSchAB-Branche als Company-Flag (B001-T01 + 3,80 %).
Rechenfälle `docs/rechenfaelle/meldewesen.md`.
**Offen (GP8+):** Jahreswechsel (GP8); All-in-UI-Bericht (GP8);
B14 längster-Urlaubs-Teil-Verfeinerung; DZ-WKO-Verifikation (8.11)
vor Wien-Mandanten; §-7-BMSVG-Fiktivfälle (Option); Arbeiter-Sub-
Konten-Endfixierung (Referenzbuchhaltung); Schwerarbeitsmeldung
(nach ASchV-Datenmodell-Bau, DM-ORG E.22-Struktur liegt bei);
§ 109a (separater Baustein); Mitteilungsfile-Import (GP8);
geringfügig/fallweise-Tarifparameter pflegen (at_mbgm_tarif).
Tests: `test_kv_katalog` + `test_entgelt_private` (GP2) +
`test_lst_private` (GP3) + `test_sv_private` (GP4) +
`test_bv_private` (GP5) + `test_account_private` (GP6)
`test_bv_private` (GP5) + `test_account_private` (GP6) +
`test_elda_l16` (GP7 Kern) + `test_meldewesen_private` (GP7) —
`.venv/bin/python -m odoo -d <testdb> -i l10n_at_hr_payroll,l10n_at_hr_payroll_private
--test-tags /l10n_at_hr_payroll,/l10n_at_hr_payroll_private --test-enable --stop-after-init
--addons-path=odoo_19.0+e.20260910/odoo-19.0+e.20260910/odoo/addons,addons`
(Lokal grün am 2026-09-12 auf Wegwerf-DB, frischer `-i`-Lauf:
**88/88 Tests** — NET-Rundungscents bucht Odoo als „Adjustment Entry"
auf das Journal-Default.)
**94/94 Tests**.)
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
'version': '19.0.6.0.0',
'version': '19.0.8.0.0',
'author': 'ixsol / gem360',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -65,6 +65,18 @@ Grundlagen: ``personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md`` und
(Zahlungsregistrierung/SEPA via ``hr_payroll_account_iso20022``).
Auszahlung über ``hr_payroll_account_iso20022``; kein VRV/MVAG
(gem360-Brücke).
- **GP7 Meldewesen (dieser Stand):** mBGM/JASV (BEST MB, DM-ORG
42.7.0: Paket/mBGM/Tarifblock/Verrechnungsbasis/-position mit
GESAMT-Prozentsatz je BSGR aus ``at_mbgm_tarif``, Abschläge
A01/A02/A03 §-2a-AMPFG, A09 UV-60, A10 AV+IE-Entfall, A15 PV-
Halbierung; BV-Basis + V01 aus GP5; Storno-R-Sätze; Fehlanzeige)
+ Versichertenmeldung (BEST VR: M3/M4 mit deklarativer Event-
Erkennung über die Meldungs-Registry, AGRD-Mapping am
Abgangsgrund, VWAZ, BBER, GERF; fallweise Anmeldung BEST MA
SART 32) — auf dem GP7-Kern-Framework
(l10n_at_hr_payroll/models/elda.py; L16 + Arbeitsstättenmeldung
+ Belege siehe Kern-Modul). NSchAB-Branche als Company-Flag
(B001-T01 + 3,80 %).
- **Seed D2 (Referenz-KVs R1):** SI-2203 „Handwerk und Gewerbe" +
SI-2748 „Metallgewerbe" (Gehaltstabellen ab 1. 1. 2026, kuratiert aus
den KV-Library-Volltexten).
@@ -94,9 +106,13 @@ gem360-Brücke).
'data/salary_rules_sv_private.xml',
'data/salary_rules_bvg_private.xml',
'data/hr_payroll_account_private.xml',
'data/rule_parameters_mbgm_private.xml',
'data/bv_kasse_seed.xml',
'views/kv_views.xml',
'views/bv_views.xml',
'views/meldewesen_views.xml',
'views/vertragsfelder_views.xml',
'report/gp8_reports.xml',
'report/bv_report.xml',
],
'countries': ['at'],
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- GP7: mBGM-Tarifsätze (Standardposition T01/T02 = GESAMT- -->
<!-- Prozentsatz je Beschäftigtengruppe aus dem ÖGK- -->
<!-- Beitragstarifsystem „Normalbetriebe Allgemeiner Teil", -->
<!-- gültig ab 01.07.2026 (.additional_docs/oegk/, Primär- -->
<!-- quelle GP4). NORMATIVE Sätze (Abschläge wie A03 laufen als -->
<!-- eigene Verrechnungspositionen): laufend = DN 18,07 + DG -->
<!-- 20,98 = 39,30 AK/… exakt: KV 7,65 + PV 22,80 + AV 5,90 + -->
<!-- UV 1,10 + WF 1,00 + IESG 0,10 = 39,05 %; SZ = KV 3,87 + -->
<!-- PV 10,25 + AV 2,95 (DN; § 67 Abs 12) + DG 20,48 (KV 3,78 + -->
<!-- PV 12,55 + AV 2,95 + UV 1,10 + IESG 0,10) = 37,55 %. -->
<!-- NSchAB-Betriebe: + 3,80 % auf B001 laufend über das Company- -->
<!-- Flag l10n_at_nschab. Lehrlinge (B044/B045) und Geringfügige -->
<!-- (B010/B030) bewusst NICHT geseedet — Werte aus dem SV- -->
<!-- Tarifsystem pflegen, sobai solche Beschäftigten vorkommen. -->
<record id="hr_rule_parameter_at_mbgm_tarif" model="hr.rule.parameter">
<field name="name">mBGM-Tarifsätze je Beschäftigtengruppe (SV-Tarifsystem)</field>
<field name="code">at_mbgm_tarif</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_mbgm_tarif_2026" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_mbgm_tarif"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'B001': {'laufend': 39.05, 'sz': 37.55}, 'B002': {'laufend': 39.05, 'sz': 37.55}}</field>
</record>
</odoo>
@@ -0,0 +1,112 @@
# Rechenfälle Meldewesen/Belege (GP7) — ELDA/eSV, L16, Belege
**Quellen:** DM-ORG 42.7.0 „Datenaustausch mit Dienstgebern", 42.
Ergänzung 07/2026 (`.firecrawl/ris/elda/` — Volltext-PDF + pdftotext;
Satzstrukturen PDF-verifiziert extrahiert 2026-09-12, Positions-
Ketten lückenlos); Pruefkatalog L16 ab 2026 (Stand 12/2025, xlsx);
KB lb-lvr-04/05 (L16), lb-mel-08 (Fristen), lb-bes-02/03 (fallweise),
lb-sva-14 (SZ-Sätze), lb-swa-05 (Schwerarbeit, out); Nutzer-
Entscheidungen 5.15.4 (Arbeitsstättenmeldung in GP7; Mitteilungsfile-
Import Rest; **FB+ = keine Personalverrechnung** — bei Steuer < 0
Arbeitnehmerveranlagung, **Kind-Modell für die FABO+-Wartung**; alles
in einem GP7). Umsetzung: Kern `l10n_at_hr_payroll` 19.0.4.0.0
(`models/elda.py`, `lohnzettel_l16.py`, `kind.py`, `meldewesen.py`,
`monatsreports.py`, `report/belege_reports.xml`), privat
19.0.7.0.0 (`models/mbgm.py`, `versichertenmeldung.py`,
`data/rule_parameters_mbgm_private.xml`). Tests: `test_elda_l16`,
`test_meldewesen_private`.
## Systematik (GP7, verifiziert)
- **ELDA-Fixlängen-Dateien** (C.1/C.2, E.1E.3): Datei = optionaler
Dateiheader (DHKZ 2×blank, DHVN 01, ENCD **ISO-8859-15**) + Daten-
bestand (Vorlaufsatz SART 00 mit BEST/VSTR/VERS → Datensätze →
Schlusssatz SART 99 mit SANZ inkl. Vorlauf/Schluss); a/n linksbündig
blank-gefüllt, n rechtsbündig null-gefüllt, **Cent ohne
Interpunktion**, Prozentsätze ×1000 (3 Nachkommastellen), SANR
lückenlos ab 1. **CRLF als Satztrenner = Praxis-Annahme** (die
DM-ORG normiert kein Trennzeichen — Parallellauf verifizieren).
- **mBGM/JASV** (BEST `MB`, Version 02; E.32): Hierarchie Paket PS
(BKNR/DGNA/BZRM/GSUM/ANZM) → mBGM G1 (REFW/VSNR/FANA/VONA/VSUM/
VERG=1) → Tarifblock T1 (BSGR/VVON) → Verrechnungsbasis BS (VBTY/
VBBT) → Verrechnungsposition V1 (VPTY/VPVZ/VPTA/RSVZ/RSUM).
**KEIN DN/DG-Zweig-Split**: T01 = GESAMT-Prozentsatz des Tarifsystems
(Parameter `at_mbgm_tarif`, normativ: laufend **39,05 %** =
KV 7,65 + PV 22,80 + AV 5,90 + UV 1,10 + WF 1,00 + IESG 0,10; SZ
**37,55 %** = DN KV 3,87 + PV 10,25 + AV 2,95 + DG 20,48). Abschläge
als EIGENE Positionen je Basis: **A01/A02/A03** = Minderung AV auf
2/1/0 % (§ 2a AMPFG — nur DN-AV, **je Verrechnungsbasis AB UND SZ**;
A03 = 2,95 % der Basis, RSVZ ), A09 = UV-Entfall 60 (1,10 %), A10
= AV+IE-Entfall (6,00 %), A15 = PV-Halbierung (11,40 %);
Konkurrenz a4: Entfall vor Minderung (A10 sperrt A0x). **BV**:
Basis `BV` (GP5, ungedeckelt) + `V01` 1,53 %. RSUM = Basis × Satz
(mBGM-Tarifierung); Konsistenz gegen GP4-Payslip-Summen als
Prüfwarnung (Toleranz 0,10 €). Storno: R-Satz je stornierter mBGM
(REFU, VSUM wie Original, ohne T/BS/V). Fehlanzeige: Paket ohne
mBGM-Sätze (ANZM 0). Fallweise/Geringfügige ohne Parametrisierung:
Lauf bricht listenweise ab (BSGR B044/B045/B010/B030 —
Tarifsystem-Werte pflegen, keine Erfindung).
- **L16 Lohnzettel Finanz** (BEST `LF`, VSTR `94` BRZ; E.13/E.14):
I1 (Länge 1100, Strukturversion 03) + L1 (Länge **3500**,
Lohnzettelversion 28, zwingend ab 01.03.2026). **KZ 210 =
Bruttobezüge gem. § 25 GESAMT** (laufende + sonstige §-67-Bezüge,
DM-ORG-Feldbeschreibung!) — deshalb ist **KZ 245 = 210 215 220
230 243** die laufende Steuerbemessung und per Konstruktion mit
der GP3-§-66-Kette konsistent. KZ-Ableitung aus den Kern-Flags
(kein paralleles Klassifikationsfeld): 210 = laufend+sonstig § 67
(ohne Abs-3/6), 215 = `_l10n_at_freibetraege_68` (§-68-Caps je LZP),
220 = sonstig ohne Abs-3/6 (inkl. 620-€-Freibetrag vor SV), 225 =
SVDN_SZ, 230 = laufende SVDN+AK+WF, 243 = 0 (nicht im Inventar),
260/BIEL = LST_LFD + LST_SZ. FB+-Felder (BFABO/KFABO/VFABO/FABO +
Kinderblock 15×123 ab Pos. 1501) aus der **Kind-Stammdaten-Wartung**
(Nutzer-Entscheidung: FB+ rechnet nicht im Lohnsteuerkern — ANV).
REFN je AG+Jahr+DV eindeutig; berichtigter Lohnzettel: ARTL 18 +
KORR K + FIND J.
- **Arbeitsstättenmeldung** § 34 Abs 6 ASVG (BEST `AD`, VSTR `ST`
Statistik Austria; E.20, SART 45, Länge 300): je AN Adresse am
31. 12. aus dem Dienstort-Partner; **GKZ** (Gemeindekennziffer) als
manuelles Partner-Feld — Warnung, wenn fehlt.
- **Versichertenmeldung** (BEST `VR`, Version 03, Länge 772; E.29):
M3 vor Arbeitsantritt (BKNR/VSNR/GEBD/FANA/VONA/ADAT/BBER 0104/
GERF/FRDV/BVAB/BVJN aus GP5/VWAZ Stunden×100), M4 7 Tage (ADAT +
EBSV, AGRD aus dem hr.departure.reason-Mapping, Standard 00),
M6 nur Ausnahmefälle; **deklarative Event-Erkennung** über die
Meldungs-Registry (keine write-diff-Hacks; idempotente Läufe).
**Fallweise** (BEST `MA`, SART 32, Länge 450, Version 07): je
Beschäftigungstag vor Arbeitsantritt; MVP: je AN/Vertragsbeginn
ein Anmeldetag (tagesgenaue Einsatztage via Work-Entries = Rest).
- **Belege (AP6)**: Lohnzettel AT (ir.actions.report auf hr.payslip,
AT-Layout mit Bezügen/Abzügen/**DG-Anteilen**/Netto; per Structure
über `report_id`), Monatsdienstliste (mBGM-Kontrollschicht je AN:
BG laufend/SZ, SV-DN/SZ, SV-DG, LSt, Netto) + Monatsjournal (je
Lohnart Summen je Lauf) auf `hr.payslip.run` (QWeb, AbstractModel).
Kein HR-Modul nutzt `account.report` — QWeb ist der framework-
gerechte Weg (Contribution-Register-Muster).
## Rechenfälle (Fixtures)
| RF | Konstellation | Erwartung |
|---|---|---|
| RF-ELDA-1 | Feld-Formatter | a/n linksbündig blank; n rechtsbündig 0-gefüllt; Cent kaufmännisch (0,005 → 1); Prozentsatz skala 3 (39,30 → 039300) |
| RF-ELDA-2 | Bestand/Datei | Vorlauf SART 00 (VSTR/BEST/EDAT TTMMJJJJ/VERS), SANR lückenlos, SANZ inkl. Rahmen, CRLF, Dateiheader ` 01ISO-8859-15…`, Umlaute ISO-8859-15-codiert |
| RF-L16-1 | November mit B13 (Jahr 2026) | KZ 210 = 4.118,82 (§ 25 gesamt!), KZ 220 = 2.059,41, KZ 245 = laufende Bemessung > 0; I1 (1.100) mit STNRA/JAHR/GESA; L1 (3.500) exakte Positionen: REFN/ARTL 01/BELZ 0111/SOZS 3/AVLN 1234/AGBD 150590/B210 0000411882; Vorlauf VSTR 94, BEST LF, VERS 28; SANZ 4 |
| RF-L16-2 | Arbeitsstätte | Satz 45 (300): BKNR/VSNR/STNR; VSTR ST, BEST AD; Warnung GKZ fehlt |
| RF-MBGM-1 | November mit B13, Angestellte (B002) | PS (BKNR/BZRM 112026/ANZM 1/GSUM = Σ VSUM), G1 (VSNR), T1 BSGR B002, Positionen T01 039050, **A03 je Basis** (AB + SZ, RSVZ ), T02 037550, V01; GSUM ≈ GP4-Beitragssummen ± 0,10 € |
| RF-VR-1 | Vertrag ab 2020, Ende 15.02.2026 | M3 (ADAT 01012020, BBER 02, GERF N, FRDV N, VWAZ 4000, BVJN J); Registry verhindert Doppelanmeldung; M4 (ADAT 15022026, EBSV, AGRD 00) |
## Offene Punkte / bewusst nicht in GP7
- **Schwerarbeitsmeldung** (DM-ORG E.22 Satzstruktur existiert!):
blockiert — keine ASchV-Work-Entry-Typen/Zähler im Modul, ASchV in
RQ-Privat ❓ nicht erschlossen. Erst nach Datenmodell-Bau.
- **§ 109a** (W1-Satz): separater Baustein (Honorare außerhalb der
Lohnverrechnung); Mitteilungs-XSDs/Prüfkatalog beschafft.
- **L17** (A1-Satz): keine AG-Pflicht. **Mitteilungsfile-Import**
(Rückantwort-Fehler ins Deklarationsmodell): GP8-Rest (5.2).
- Geringfügigen-mBGM (B010/B030-Sätze, Z04-Jahreszahlung + 2,5 %):
parametrisieren, sobai vorkommend; mBGM je Lauf bei Mehrfachläufen
im Monat; M6/Änderung; FB+-Lohnsteuermechanik (ANV-Sache);
VSNR-Prüfziffer-Algorithmus (SVS-Quelle beschaffen);
tagesgenaue fallweise-Einsatztage (Work-Entry-Auswertung).
- Referenzbuchhaltung/Parallellauf: CRLF-Satztrenner, ELDA-Kanäle
(Sendemodul https/ftps 40/220 MB), Fristenkalender (GP8).
@@ -6,4 +6,7 @@ from . import bv_kasse
from . import abfertigung_private
from . import bv_report
from . import account_chart_private
from . import account_posting_private
from . import account_posting_private
from . import mbgm
from . import versichertenmeldung
from . import fristen_private
@@ -0,0 +1,48 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP8-Privat: Verlinkung des Fristenkalenders mit den
privatwirtschaftlichen GP7-Deklarationen (L16/mBGM-Sprungziele)."""
from odoo import fields, models
class L10nAtPayrollFrist(models.Model):
_inherit = 'l10n.at.payroll.frist'
ref_mbgm_id = fields.Many2one(
'l10n.at.payroll.mbgm', string='mBGM-Deklaration',
help='Sprungziel für SV-Fristen (Privatmodul — das '
'Kernmodell kennt mBGM nicht).')
def action_zum_objekt(self):
"""Sprung zur GP7-Deklaration (L16 oder mBGM)."""
self.ensure_one()
result = super().action_zum_objekt()
if result is not True:
return result
if self.ref_mbgm_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'l10n.at.payroll.mbgm',
'res_id': self.ref_mbgm_id.id,
'view_mode': 'form',
}
return True
def _l10n_at_verlinke_deklarationen(self):
"""Sprungziele für offene L16- und mBGM-Fristen setzen: neueste
Deklaration des Bezugsjahres verlinken, wenn vorhanden."""
for frist in self:
if frist.fristart in ('l16', 'arbeitsstaette'):
l16 = self.env['l10n.at.payroll.l16'].search([
('company_id', '=', frist.company_id.id),
('jahr', '=', frist.jahr),
], order='id desc', limit=1)
frist.ref_l16_id = l16 or False
elif frist.fristart == 'sv_mbgm':
mbgm = self.env['l10n.at.payroll.mbgm'].search([
('company_id', '=', frist.company_id.id),
('beitragsmonat', '>=',
'%d-%02d-01' % (frist.jahr, frist.monat or 1)),
('beitragsmonat', '<=',
'%d-%02d-01' % (frist.jahr, frist.monat or 12)),
], order='id desc', limit=1)
frist.ref_mbgm_id = mbgm or False
@@ -0,0 +1,506 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Monatliche Beitragsgrundlagenmeldung mBGM/JASV (GP7/AP5) — DM-ORG
42.7.0, Kapitel E.32 (BEST ``MB``, mBGM-Version 02; PDF-verifizierte
Extraktion 2026-09-12).
Hierarchie: Paket (SART ``PS`` Selbstabrechner, Länge 305) → mBGM
(SART ``G1``, 326) → Tarifblock (SART ``T1``, 42) → Verrechnungsbasis
(``BS``, 33) → Verrechnungsposition (``V1``, 42).
Fachliche Grundlagen:
- **KEIN DN/DG- oder Zweig-Split**: die Standardposition ``T01``
trägt den GESAMT-Prozentsatz des Tarifsystems (Parameter
``at_mbgm_tarif`` — BSGR-spezifisch; Werte aus dem ÖGK-
Beitragstarifsystem, `.additional_docs/oegk/`, jeweils
parametrisiert zu pflegen). Fehlende BSGR-Sätze brechen den Lauf
listenweise ab (keine Erfindung von Tarifen).
- **Abschläge als eigene Verrechnungspositionen** (D.60, verifiziert):
A01/A02/A03 = Minderung AV auf 2/1/0 % (§ 2a AMPFG, DN-seitig —
Abschlag = AB × (2,95 Zielsatz) %), A09 = UV-Entfall ab 60
(1,10 %), A10 = AV+IE-Entfall Pensionsanspruch (6,00 %), A15 =
PV-Halbierung Bonusphase (11,40 %). A02/A10 schließen einander aus
(Konkurrenz a4: Entfall vor Minderung — wie der GP4-Kern rechnet).
- **BV (BMSVG)**: eigene Verrechnungsbasis ``BV`` (ungedeckelt, GP5)
+ Position ``V01`` (1,53 %); SZ-Basis ``SZ`` (§-54-gedeckelt) +
Position ``T02`` mit dem SZ-Gesamtsatz.
- **RSUM aus Basis × Satz** (mBGM-eigene Tarifierung); Konsistenz
gegen die GP4-Payslip-Summen als Prüfwarnung (Toleranz 0,10 €).
- **Fehlanzeige**: Paket ohne mBGM-Sätze (ANZM 0) für Beitragsmonate
ohne meldbare Daten (GP5-Deferral; DM-ORG-Form ⚠ Parallellauf
verifizieren). **Storno**: R1-Sätze mit REFU + identischem BZRM
(VSUM = gestornierte VSUM) für den Nachtragslauf; Nachreichung als
neue Deklaration (GP4-Deferral Aufrollung).
- Fallweise Beschäftigte (G3/G4 mit tagesgenauen Tarifblöcken) und
Geringfügige ohne Tarifparameter: dokumentierter Rest — der Lauf
bricht listenweise ab, bis `at_mbgm_tarif` gepflegt ist.
Dateibasiert (Prämisse P7): Generierung + Prüfprotokoll; Übertragung
beim Mandanten. Fristen: Selbstabrechner 15., Vorschreibebetriebe 7.
des Folgemonats (lb-mel-08) — Fristenkalender GP8."""
from datetime import date, timedelta
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.addons.l10n_at_hr_payroll.models.elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
l10n_at_euro_cent,
)
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \
l10n_at_namen
# Paket (E.32, Selbstabrechner PS) — nach IDTEIL, Länge 305.
MBGM_PAKET = [
(2, 'REFP', 21, 40, 'a'),
(3, 'BKNR', 61, 10, 'a'),
(4, 'DGNA', 71, 70, 'a'),
(5, 'MPKE', 141, 30, 'a'),
(6, 'JAGB', 171, 1, 'a'),
(7, 'DTEL', 172, 50, 'a'),
(8, 'MAIL', 222, 60, 'a'),
(9, 'BZRM', 282, 6, 'n'), # MMJJJJ
(10, 'GSVZ', 288, 1, 'a'),
(11, 'GSUM', 289, 11, 'n'), # Σ VSUM (Cent)
(12, 'ANZM', 300, 6, 'n'), # Anzahl mBGM (G/R-Sätze)
]
# mBGM-Satz (E.32, G1 Selbstabrechner) — nach IDTEIL, Länge 326.
MBGM_SATZ = [
(2, 'REFW', 21, 40, 'a'),
(3, 'REFU', 61, 40, 'a'),
(4, 'REFV', 101, 40, 'a'),
(5, 'VSNR', 141, 10, 'n'),
(6, 'FANA', 151, 70, 'a'),
(7, 'VONA', 221, 70, 'a'),
(8, 'VSUM', 291, 11, 'n'), # Σ RSUM (Cent)
(9, 'VERG', 302, 1, 'a'), # 1 = SV+BV mit Versicherungszeit
(10, 'INF1', 303, 12, 'a'),
(11, 'INF2', 315, 12, 'a'),
]
# Tarifblock (E.32, T1) — nach IDTEIL, Länge 42.
MBGM_TARIFBLOCK = [
(2, 'BSGR', 21, 4, 'a'), # Beschäftigtengruppe (SV-Tarifsystem)
(3, 'ERGB', 25, 15, 'a'), # 5 × 3-stellige Ergänzungen
(4, 'VVON', 40, 2, 'n'), # Beginn der Verrechnung (Tag)
(5, 'KEUE', 42, 1, 'a'), # enthält KE/UE
]
# Verrechnungsbasis (E.32, BS) — nach IDTEIL, Länge 33.
MBGM_BASIS = [
(2, 'VBTY', 21, 2, 'a'), # AB/SZ/BV/UU …
(3, 'VBBT', 23, 11, 'n'), # Cent
]
# Verrechnungsposition (E.32, V1) — nach IDTEIL, Länge 42.
MBGM_POSITION = [
(2, 'VPTY', 21, 3, 'a'), # T01/T02/V01/A01…A15/Z01 …
(3, 'VPVZ', 24, 1, 'a'), # Vorzeichen Prozentsatz
(4, 'VPTA', 25, 6, 'n'), # Prozentsatz ×1000 (3 Nachkommastellen)
(5, 'RSVZ', 31, 1, 'a'), # Vorzeichen RSUM
(6, 'RSUM', 32, 11, 'n'), # Cent
]
class L10nAtPayrollMbgm(models.Model):
_name = 'l10n.at.payroll.mbgm'
_description = 'Monatliche Beitragsgrundlagenmeldung (mBGM/JASV)'
_inherit = ['mail.thread']
_order = 'beitragsmonat desc, id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
beitragsmonat = fields.Date(
required=True, string='Beitragsmonat',
help='Erster Tag des Beitragsmonats (MMJJJJ).')
art = fields.Selection(
[('MB', 'mBGM (regulär)'),
('ST', 'Storno (R-Sätze, iVm REFU)'),
('FZ', 'Fehlanzeige (Paket ohne mBGM-Sätze)')],
required=True, default='MB', tracking=True)
storno_id = fields.Many2one(
'l10n.at.payroll.mbgm', string='Stornierte Deklaration',
help='Art ST: Referenz auf die ursprüngliche Deklaration; '
'REFU der R-Sätze = REFW der stornierten mBGM je AN.')
line_ids = fields.One2many(
'l10n.at.payroll.mbgm.line', 'mbgm_id', string='mBGM-Sätze')
datei = fields.Binary(attachment=True, 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 = [(
'mbgm_company_monat_art_uniq',
'unique(company_id, beitragsmonat, art)',
'Für diesen Beitragsmonat existiert bereits eine mBGM-'
'Deklaration dieser Art.')]
def _l10n_at_slips(self):
self.ensure_one()
erster = self.beitragsmonat
letzter = (date(erster.year, erster.month, 28)
+ timedelta(days=4)).replace(day=1) \
- timedelta(days=1)
return self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('date_from', '>=', erster),
('date_from', '<=', letzter),
('state', 'in', ('validated', 'paid')),
])
def _l10n_at_slip(self, employee):
"""Der (eindeutige) Lauf des AN im Beitragsmonat."""
self.ensure_one()
erster = self.beitragsmonat
letzter = (date(erster.year, erster.month, 28)
+ timedelta(days=4)).replace(day=1) \
- timedelta(days=1)
return self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('employee_id', '=', employee.id),
('date_from', '>=', erster),
('date_from', '<=', letzter),
('state', 'in', ('validated', 'paid'))], limit=1)
def _l10n_at_bsgr(self, version, slip):
"""Beschäftigtengruppe (SV-Tarifsystem): B001 Arbeiter, B002
Angestellte; Lehrlinge B044/B045 und geringfügig/fallweise
(B010/B030) ohne Default-Parameter → Lauf bricht ab, bis
`at_mbgm_tarif` gepflegt ist (Tarifsystem-Werte, keine
Erfindung)."""
if version.l10n_at_lehrling:
return 'B044' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else 'B045'
if slip.l10n_at_geringfuegig or version.l10n_at_fallweise:
return 'B030'
return 'B001' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else 'B002'
def _l10n_at_tarif(self, bsgr):
self.ensure_one()
tarif = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_mbgm_tarif', self.beitragsmonat,
raise_if_not_found=False) or {}
return tarif.get(bsgr)
def _l10n_at_positionen(self, slip, tarif):
"""Verrechnungspositionen je Tarifblock: T01 (Gesamtsatz,
NSchAB-Zweig via Company-Flag) + Abschläge aus den GP4-
Stichtagsflags; T02 auf SZ; V01 auf BV-Basis. Rückgabe:
[(vpty, vpta, rsum_cent, basis_typ), …] — RSUM aus Basis ×
Satz (mBGM-Tarifierung); Abschläge negativ (RSVZ '-')."""
version = slip.version_id
ab = sum(basis for _m, basis
in slip._l10n_at_sv_basis_laufen_monate())
nschab = 3.80 if (self.company_id.l10n_at_nschab
and not version.l10n_at_lehrling
and getattr(version, 'l10n_at_personalgruppe',
'') == 'arbeiter') else 0.0
satz_laufen = float(tarif['laufend']) + nschab
positionen = [('T01', satz_laufen, ab * satz_laufen / 100.0,
'AB')]
# §-2a-AMPFG: Minderung nur des DN-AV (2,95 %), als eigene
# Abschlagsposition auf JEDER betroffenen Verrechnungsbasis
# (AB und SZ — der Abschlag senkt den Gesamtsatz je Basis;
# DN-seitig, GP4-Konvention). Codes: 0 % → A03, 1 % → A02,
# 2 % → A01 (D.60, verifiziert).
minderung = 0.0
monat = self.beitragsmonat
if not (version.l10n_at_alv_entfall_ab
and monat >= version.l10n_at_alv_entfall_ab):
staffel = self.env['hr.rule.parameter'] \
._get_parameter_from_code(
'at_sv_av_staffel_ampfg', monat,
raise_if_not_found=False) or {}
for _von, bis, dn_satz in staffel.get('tiers', []):
if ab and ab <= float(bis):
minderung = 2.95 - float(dn_satz)
break
if minderung > 0:
code = ('A03' if minderung >= 2.95
else 'A02' if abs(minderung - 1.95) < 0.01
else 'A01')
positionen.append(
(code, 0.0, -ab * minderung / 100.0, 'AB'))
else:
# A10 = Entfall AV+IE (DN-AV + DG-AV + DG-IESG).
positionen.append(
('A10', 0.0, -ab * 6.00 / 100.0, 'AB'))
if version.l10n_at_uv_entfall_ab \
and monat >= version.l10n_at_uv_entfall_ab:
positionen.append(('A09', 0.0, -ab * 1.10 / 100.0, 'AB'))
if version.l10n_at_pv_bonusphase_ab \
and monat >= version.l10n_at_pv_bonusphase_ab:
positionen.append(('A15', 0.0, -ab * 11.40 / 100.0, 'AB'))
sz_basis = slip._l10n_at_svdn_basis_sz()
if sz_basis > 0:
satz_sz = float(tarif['sz'])
positionen.append(
('T02', satz_sz, sz_basis * satz_sz / 100.0, 'SZ'))
if minderung > 0:
code = ('A03' if minderung >= 2.95
else 'A02' if abs(minderung - 1.95) < 0.01
else 'A01')
positionen.append(
(code, 0.0, -sz_basis * minderung / 100.0, 'SZ'))
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 action_erzeugen(self):
for deklaration in self:
fehler = []
warnungen = []
company = deklaration.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
fehler.append('Beitragskontonummer (BKNR) fehlt.')
vstr = (bknr or '')[:2]
deklaration.line_ids.unlink()
datensaetze = []
summe_gesamt = 0
anzahl = 0
if deklaration.art != 'FZ':
slips = deklaration._l10n_at_slips()
nach_an = {}
for slip in slips:
nach_an.setdefault(slip.employee_id, []).append(slip)
for employee, employee_slips in nach_an.items():
version = employee.version_id
if not (version and version.ssnid):
fehler.append(
'Mitarbeiter %s ohne Versicherungsnummer.'
% employee.name)
continue
if len(employee_slips) > 1:
fehler.append(
'Mitarbeiter %s mit mehreren Läufen im '
'Beitragsmonat — mBGM je Lauf ist ein '
'dokumentierter Rest.' % employee.name)
continue
slip = employee_slips[0]
if deklaration.art == 'ST':
# Storno: R1-Satz je stornierter mBGM mit
# REFU = REFW des Originals, VSUM wie Original
# (E.32.2.8 — ohne T-/BS-/V-Sätze).
original = deklaration.storno_id.line_ids \
.filtered(lambda l: l.employee_id == employee)
if not original:
fehler.append(
'Storno %s: keine stornierte mBGM '
'am Mitarbeiter gefunden.' % employee.name)
continue
refw = 'MBGM-ST-%d-%s-%d' % (
company.id,
deklaration.beitragsmonat.strftime('%Y%m'),
employee.id)
storno_satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326,
bezeichnung='Storno %s' % employee.name)
storno_satz.setze('SART', 'R1')
storno_satz.setze('REFW', refw)
storno_satz.setze('REFU', original.refw)
storno_satz.setze('VSNR', version.ssnid)
storno_satz.setze('VSUM', abs(original.vsum),
cent=True)
datensaetze.append(storno_satz)
deklaration.env['l10n.at.payroll.mbgm.line']\
.create({
'mbgm_id': deklaration.id,
'employee_id': employee.id,
'versicherungsnummer': version.ssnid,
'bsgr': original.bsgr,
'refw': refw,
'vsum': original.vsum,
})
summe_gesamt += original.vsum
anzahl += 1
continue
bsgr = deklaration._l10n_at_bsgr(version, slip)
tarif = deklaration._l10n_at_tarif(bsgr)
if not tarif:
fehler.append(
'Mitarbeiter %s: kein Tarifsatz für BSGR '
'%s parametrisiert (at_mbgm_tarif, Werte '
'aus dem SV-Tarifsystem pflegen).'
% (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 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))
deklaration.env['l10n.at.payroll.mbgm.line'].create({
'mbgm_id': deklaration.id,
'employee_id': employee.id,
'versicherungsnummer': version.ssnid,
'bsgr': bsgr,
'refw': 'MBGM-%d-%s-%d' % (
company.id,
deklaration.beitragsmonat.strftime('%Y%m'),
employee.id),
'vsum': vsum,
})
datensaetze.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)
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])
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',
})
return True
def _l10n_at_mbgm_datensaetze(self, employee, version, bsgr,
positionen, vsum):
"""G1 + Tarifblock + je Basis (AB/SZ/BV) die Positionen als
eigene Sätze (BS/V folgen hierarchisch dem Tarifblock)."""
self.ensure_one()
slip = self._l10n_at_slip(employee)
mbgm_satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326,
bezeichnung='mBGM %s' % employee.name)
mbgm_satz.setze('SART', 'G1')
mbgm_satz.setze('REFW', 'MBGM-%d-%s-%d' % (
self.company_id.id,
self.beitragsmonat.strftime('%Y%m'), employee.id))
mbgm_satz.setze('VSNR', version.ssnid)
familienname, vorname = l10n_at_namen(employee)
mbgm_satz.setze('FANA', familienname[:70])
mbgm_satz.setze('VONA', (vorname or '')[:70])
mbgm_satz.setze('VSUM', abs(vsum), cent=True)
mbgm_satz.setze('VERG', '1')
tarifblock = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_TARIFBLOCK, 326,
bezeichnung='Tarifblock %s' % employee.name)
tarifblock.setze('SART', 'T1')
tarifblock.setze('BSGR', bsgr)
tarifblock.setze('VVON', '1')
saetze = [mbgm_satz, tarifblock]
for code, satz_prozent, rsum, basis_typ in positionen:
basis = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_BASIS, 326,
bezeichnung='Basis %s %s' % (basis_typ,
employee.name))
basis.setze('SART', 'BS')
basis.setze('VBTY', basis_typ)
basis_wert = abs(rsum) * 100.0 / satz_prozent \
if satz_prozent else 0.0
if basis_typ == 'AB':
basis_wert = sum(
b for _m, b in
slip._l10n_at_sv_basis_laufen_monate())
elif basis_typ == 'SZ':
basis_wert = slip._l10n_at_svdn_basis_sz()
else:
basis_wert = slip._at_bv_basis_laufen() \
+ slip._at_bv_basis_sonst()
basis.setze('VBBT', basis_wert, cent=True)
position = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_POSITION, 326,
bezeichnung='Position %s %s' % (code, employee.name))
position.setze('SART', 'V1')
position.setze('VPTY', code)
position.setze('VPTA', satz_prozent, skala=3)
position.setze('RSVZ', '-' if rsum < 0 else '+')
position.setze('RSUM', abs(rsum), cent=True)
saetze.extend([basis, position])
return saetze
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_at_nschab = fields.Boolean(
string='AT Schlechtwetterentschädigungsbeitrag-Branche',
help='Betrieb mit Schlechtwetterentschädigung (ÖGK-'
'Tarifsystem „Normalbetriebe mit '
'Schlechtwetterentschädigung“): Arbeiter (BSGR B001) '
'erhalten im mBGM-T01 den um NSchAB 3,80 % erhöhten '
'Gesamtsatz.')
class L10nAtPayrollMbgmLine(models.Model):
_name = 'l10n.at.payroll.mbgm.line'
_description = 'mBGM — Satzdaten je AN'
_order = 'employee_name'
mbgm_id = fields.Many2one(
'l10n.at.payroll.mbgm', required=True, ondelete='cascade',
index=True)
employee_id = fields.Many2one('hr.employee', required=True)
employee_name = fields.Char(related='employee_id.name', store=True,
readonly=True)
versicherungsnummer = fields.Char()
bsgr = fields.Char(string='Beschäftigtengruppe')
refw = fields.Char(string='Referenzwert (REFW)')
vsum = fields.Monetary(
string='VSUM', currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency', related='mbgm_id.company_id.currency_id')
@@ -0,0 +1,406 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Versichertenmeldung reduziert (GP7/AP5) — DM-ORG 42.7.0 Kapitel
E.29 (BEST ``VR``, Version 03, Satzlänge 772; PDF-verifizierte
Feldtabelle 2026-09-12): Anmeldung M3 (vor Arbeitsantritt), Abmeldung
M4 (7 Kalendertage ab PV-Ende), Änderungsmeldung M6 nur für
Ausnahmefälle (Inhaltsänderungen laufen über die mBGM; lb-mel-08).
Fallweise Anmeldung (BEST ``MA``, SART 32/33, Länge 450): je
Beschäftigungstag vor Arbeitsantritt (§ 33 Abs 1a Z 1 ASVG).
Event-Modell deklarativ (Dimona-auto-Muster, ohne write-diff):
offene Anmeldungen = älteste Version eines Beschäftigungsspannen-
Beginns ohne M3-Eintrag in der Meldungs-Registry; offene Abmeldungen
= beendete Versionen ohne M4-Eintrag. Die Registry
(``l10n.at.payroll.vr.event``) protokolliert gesendete Meldungen
(REFW/ADAT), damit erneute Läufe idempotent bleiben.
Felder: BBER 01 Arbeiter/02 Angestellte(r)/0304 Lehrling (D.39),
GERF geringfügig (J/N), FRDV freies Dienstverhältnis, ADAT An-/
Abmeldedatum, EBSV arbeitsrechtliches Ende vs ADAT (UEL/KE-Verlänger
ung der PV sind nicht modelliert → ADAT = contract_date_end),
AGRD eSV-Abmeldegrund (Mapping am hr.departure.reason, Standard 00),
BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ vereinbarte Wochenarbeitszeit
(Stunden ×100, neu für Meldedaten ab 01.01.2026).
Dateibasiert (Prämisse P7); Bestätigungen/Übertragung beim Mandanten."""
from datetime import date
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.addons.l10n_at_hr_payroll.models.elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
)
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \
l10n_at_namen
# Versichertenmeldung (E.29, Version 03) — nach IDTEIL, Länge 772.
VR_SATZ = [
(2, 'REFW', 21, 40, 'a'),
(3, 'REFU', 61, 40, 'a'),
(4, 'BKNR', 101, 10, 'a'),
(5, 'DGNA', 111, 70, 'a'),
(6, 'DTEL', 181, 50, 'a'),
(7, 'MAIL', 231, 60, 'a'),
(8, 'INF1', 291, 12, 'a'),
(9, 'INF2', 303, 12, 'a'),
(10, 'VSNR', 315, 10, 'n'),
(11, 'GEBD', 325, 8, 'n'), # TTMMJJJJ
(12, 'REFV', 333, 40, 'a'),
(13, 'FANA', 373, 70, 'a'),
(14, 'VONA', 443, 70, 'a'),
(15, 'ADAT', 513, 8, 'n'), # TTMMJJJJ
(16, 'BDAT', 521, 8, 'n'),
(17, 'RDAT', 529, 8, 'n'),
(18, 'BBER', 537, 2, 'a'), # Beschäftigungsbereich (D.39)
(19, 'GERF', 539, 1, 'a'), # geringfügig J/N
(20, 'FRDV', 540, 1, 'a'), # freier Dienstvertrag J/N
(21, 'EBSV', 541, 8, 'n'), # Ende des Beschäftigungsverhältnisses
(22, 'AGRD', 549, 2, 'a'), # Abmeldegrund (D.22)
(23, 'SAGR', 551, 20, 'a'),
(24, 'KEAB', 571, 8, 'n'),
(25, 'KEBI', 579, 8, 'n'),
(26, 'UEAB', 587, 8, 'n'),
(27, 'UEBI', 595, 8, 'n'),
(28, 'BVAB', 603, 8, 'n'), # BV-Beitrag ab
(29, 'BVEN', 611, 8, 'n'),
(30, 'BVJN', 619, 1, 'a'), # Betriebliche Vorsorge J/N
(31, 'UMDA', 620, 8, 'n'),
(32, 'RUMD', 628, 8, 'n'),
(33, 'SOUM', 636, 1, 'a'),
(34, 'ZTUM', 637, 2, 'a'),
(35, 'ZKUM', 639, 10, 'a'),
(36, 'RWUM', 649, 40, 'a'),
(37, 'RUUM', 689, 40, 'a'),
(38, 'BKUM', 729, 40, 'a'),
(39, 'VWAZ', 769, 4, 'n'), # Stunden ×100
]
# Fallweise Anmeldung (E.18, Version 07) — nach IDTEIL, Länge 450.
FALLWEISE_SATZ = [
(2, 'BKNR', 21, 10, 'a'),
(3, 'DGNA', 31, 70, 'a'),
(4, 'VSNR', 101, 10, 'n'),
(5, 'GEBD', 111, 8, 'n'),
(6, 'FANA', 119, 70, 'a'),
(7, 'VONA', 189, 70, 'a'),
(8, 'GESL', 259, 1, 'n'), # 1 männlich/2 weiblich …
(9, 'ADAT', 260, 8, 'n'), # Tag der fallweisen Beschäftigung
(10, 'BKFZ', 268, 3, 'a'),
(11, 'BPLZ', 271, 9, 'a'),
(12, 'BORT', 280, 40, 'a'),
(14, 'WOBD', 321, 12, 'a'),
(15, 'ZOBD', 333, 15, 'a'),
(17, 'REFN', 410, 30, 'a'),
(18, 'VTAZ', 440, 4, 'n'), # Stunden ×100
]
class L10nAtPayrollVrEvent(models.Model):
"""Meldungs-Registry: je gesendeter Meldung ein Eintrag (Art,
ADAT, REFW) — Grundlage für die deklarative Event-Erkennung der
offenen An-/Abmeldungen."""
_name = 'l10n.at.payroll.vr.event'
_description = 'Versichertenmeldung — gesendete Meldungen'
_order = 'adatum, id'
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
employee_id = fields.Many2one('hr.employee', required=True,
index=True, ondelete='cascade')
version_id = fields.Many2one('hr.version', required=True,
ondelete='cascade')
art = fields.Selection(
[('M3', 'Anmeldung'), ('M4', 'Abmeldung'), ('M6', 'Änderung'),
('MA', 'Fallweise Anmeldung')],
required=True)
adatum = fields.Date(required=True)
refw = fields.Char(required=True)
class L10nAtPayrollVr(models.Model):
_name = 'l10n.at.payroll.vr'
_description = 'Versichertenmeldung (An-/Abmeldung) — ELDA-Lauf'
_inherit = ['mail.thread']
_order = 'id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
stichtag = fields.Date(
default=fields.Date.today,
help='Anmeldungen bis zu diesem Tag (M3 ist eigentlich VOR '
'Arbeitsantritt zu übermitteln — der Lauf meldet '
'beginnende Beschäftigungsverhältnisse).')
nur_fallweise = fields.Boolean(
string='Nur fallweise Anmeldungen (BEST MA)',
help='Erzeugt ausschließlich Anmeldungen fallweise '
'Beschäftigter (je Beschäftigungstag, SART 32).')
datei = fields.Binary(attachment=True, 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)
event_ids = fields.Many2many(
'l10n.at.payroll.vr.event', string='Gesendete Meldungen',
copy=False)
def _l10n_at_offene_anmeldungen(self):
"""Älteste aktive Version je AN ohne M3-Eintrag: Beginn eines
Beschäftigungsverhältnisses (früheste contract_date_start)."""
self.ensure_one()
offene = []
versionen = self.env['hr.version'].search([
('company_id', '=', self.company_id.id),
('contract_date_start', '!=', False),
('active', '=', True),
]).with_company(self.company_id)
nach_an = {}
for version in versionen:
nach_an.setdefault(version.employee_id, version)
if version.contract_date_start \
< nach_an[version.employee_id].contract_date_start:
nach_an[version.employee_id] = version
registry = self.env['l10n.at.payroll.vr.event'].search([
('company_id', '=', self.company_id.id),
('art', 'in', ('M3', 'MA')),
])
gesehen = {(e.employee_id.id, e.adatum) for e in registry}
for employee, version in nach_an.items():
if (employee.id, version.contract_date_start) in gesehen:
continue
if version.contract_date_start > self.stichtag:
continue
offene.append(version)
return sorted(offene, key=lambda v: v.contract_date_start)
def _l10n_at_offene_abmeldungen(self):
self.ensure_one()
abmeldungen = []
versionen = self.env['hr.version'].search([
('company_id', '=', self.company_id.id),
('contract_date_end', '!=', False),
]).with_company(self.company_id)
registry = self.env['l10n.at.payroll.vr.event'].search([
('company_id', '=', self.company_id.id),
('art', '=', 'M4'),
])
gesehen = {(e.employee_id.id, e.adatum) for e in registry}
for version in versionen:
if (version.employee_id.id, version.contract_date_end) \
in gesehen:
continue
abmeldungen.append(version)
return sorted(abmeldungen, key=lambda v: v.contract_date_end)
def _l10n_at_bber(self, version):
if version.l10n_at_lehrling:
return '03' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else '04'
return '01' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else '02'
def _l10n_at_geringfuegig(self, version):
"""Vorausschauende Geringfügigkeits-Einschätzung zur Anmeldung:
Kataloggehalt/Überzahlung gegen die GFG (§ 5 Abs 2 ASVG); die
endgültige Bewertung läuft monatlich über den GP4-Kern."""
gfg = float(self.env['hr.rule.parameter']
._get_parameter_from_code(
'at_sv_gfg_monatlich', version.contract_date_start,
raise_if_not_found=False) or 0.0)
gehalt = version.wage \
or (version.kv_gruppe_id._get_gehalt(
version.erfahrungsstufe, version.contract_date_start)
if getattr(version, 'kv_gruppe_id', False) else 0.0)
ueberzahlung = getattr(version, 'ueberzahlung', 0.0) or 0.0
if ueberzahlung:
gehalt = ueberzahlung
return bool(gfg and gehalt and gehalt <= gfg)
def action_erzeugen(self):
for lauf in self:
fehler = []
company = lauf.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
raise UserError('Beitragskontonummer (BKNR) fehlt.')
datensaetze = []
ereignisse = []
vstr = bknr[:2]
if lauf.nur_fallweise:
versionen = self.env['hr.version'].search([
('company_id', '=', company.id),
('l10n_at_fallweise', '=', True),
('active', '=', True),
])
registry = self.env['l10n.at.payroll.vr.event'].search([
('company_id', '=', company.id),
('art', '=', 'MA'),
])
gesehen = {(e.employee_id.id, e.adatum)
for e in registry}
for version in versionen:
if not version.ssnid:
fehler.append('%s ohne Versicherungsnummer.'
% version.employee_id.name)
continue
# MVP: je AN ein Anmeldetag (der Vertragsbeginn);
# tagesgenaue Einsatztage = documented Rest
# (Work-Entry-Auswertung).
adat = version.contract_date_start
if not adat or (version.employee_id.id, adat) \
in gesehen:
continue
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + FALLWEISE_SATZ, 450,
bezeichnung='Fallweise %s'
% version.employee_id.name)
satz.setze('SART', '32')
satz.setze('VSTR', vstr)
satz.setze('BKNR', bknr)
satz.setze('DGNA', company.name[:70])
satz.setze('VSNR', version.ssnid)
satz.setze_datum('GEBD', version.employee_id.birthday)
satz.setze('FANA', (version.private_last_name
or '')[:70])
satz.setze('VONA', (version.private_first_name
or '')[:70])
satz.setze('GESL', '2'
if version.sex == 'female' else '1')
satz.setze_datum('ADAT', adat)
kalender = version.resource_calendar_id
if kalender:
satz.setze('VTAZ', kalender.hours_per_day,
skala=2)
refn = 'MA-%d-%s-%d' % (
company.id, adat.strftime('%Y%m%d'),
version.employee_id.id)
satz.setze('REFN', refn)
datensaetze.append(satz)
ereignisse.append((version, 'MA', adat, refn))
else:
for version in lauf._l10n_at_offene_anmeldungen():
if not version.ssnid:
fehler.append(
'Anmeldung %s ohne Versicherungsnummer.'
% version.employee_id.name)
continue
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
bezeichnung='M3 %s' % version.employee_id.name)
refw = 'M3-%d-%s-%d' % (
company.id,
version.contract_date_start.strftime('%Y%m%d'),
version.employee_id.id)
satz.setze('SART', 'M3')
satz.setze('VSTR', vstr)
satz.setze('REFW', refw)
satz.setze('BKNR', bknr)
satz.setze('DGNA', company.name[:70])
satz.setze('VSNR', version.ssnid)
satz.setze_datum(
'GEBD', version.employee_id.birthday)
familienname, vorname = l10n_at_namen(
version.employee_id)
satz.setze('FANA', familienname[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze_datum('ADAT', version.contract_date_start)
satz.setze('BBER', lauf._l10n_at_bber(version))
satz.setze('GERF', 'J' if lauf
._l10n_at_geringfuegig(version) else 'N')
satz.setze('FRDV', 'N')
if getattr(version, 'l10n_at_abfertigung_neu',
False):
satz.setze('BVAB',
version.contract_date_start
.strftime('%d%m%Y'))
satz.setze('BVJN', 'J')
kalender = version.resource_calendar_id
if kalender:
satz.setze('VWAZ', kalender.hours_per_week,
skala=2)
datensaetze.append(satz)
ereignisse.append((version, 'M3',
version.contract_date_start,
refw))
for version in lauf._l10n_at_offene_abmeldungen():
if not version.ssnid:
fehler.append(
'Abmeldung %s ohne Versicherungsnummer.'
% version.employee_id.name)
continue
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
bezeichnung='M4 %s' % version.employee_id.name)
refw = 'M4-%d-%s-%d' % (
company.id,
version.contract_date_end.strftime('%Y%m%d'),
version.employee_id.id)
agrd = getattr(
version.departure_reason_id, 'l10n_at_agrd',
False) or '00'
satz.setze('SART', 'M4')
satz.setze('VSTR', vstr)
satz.setze('REFW', refw)
satz.setze('BKNR', bknr)
satz.setze('DGNA', company.name[:70])
satz.setze('VSNR', version.ssnid)
satz.setze_datum(
'GEBD', version.employee_id.birthday)
familienname, vorname = l10n_at_namen(
version.employee_id)
satz.setze('FANA', familienname[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze_datum('ADAT', version.contract_date_end)
satz.setze_datum('EBSV', version.contract_date_end)
satz.setze('AGRD', agrd)
datensaetze.append(satz)
ereignisse.append((version, 'M4',
version.contract_date_end,
refw))
if fehler:
lauf.protokoll = 'FEHLER:\n' + '\n'.join(fehler)
raise UserError('Versichertenmeldung mit Fehlern '
'abgebrochen:\n' + '\n'.join(fehler))
if not datensaetze:
raise UserError('Keine offenen Meldungen zum '
'Stichtag %s.' % lauf.stichtag)
bestand = L10nAtEldaBestand(
'MA' if lauf.nur_fallweise else 'VR', vstr, '07'
if lauf.nur_fallweise else '03',
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])
ereignis_werte = []
for version, art, adatum, refw in ereignisse:
ereignis_werte.append({
'company_id': company.id,
'employee_id': version.employee_id.id,
'version_id': version.id,
'art': art,
'adatum': adatum,
'refw': refw,
})
ereignisse = self.env['l10n.at.payroll.vr.event'].create(
ereignis_werte) if ereignis_werte else \
self.env['l10n.at.payroll.vr.event']
lauf.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'versichertenmeldung_%s.txt' % (
fields.Date.today().strftime('%Y%m%d'),),
'event_ids': [(6, 0, ereignisse.ids)],
'state': 'done',
'protokoll': 'Meldungen: %d' % len(datensaetze),
})
return True
@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP8: All-in-Deckungsrechnung (QWeb auf hr.payslip) und -->
<!-- DAG-Ausweis (QWeb auf hr.payslip.run). -->
<!-- ============================================================ -->
<record id="action_report_allin_deckung" model="ir.actions.report">
<field name="name">All-in-Deckungsrechnung (§ 2g AVRAG)</field>
<field name="model">hr.payslip</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll_private.report_allin_deckung</field>
<field name="report_file">l10n_at_hr_payroll_private.report_allin_deckung</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip"/>
<field name="binding_type">report</field>
</record>
<record id="action_report_dag_ausweis" model="ir.actions.report">
<field name="name">DAG-Ausweis (§ 1 DAG)</field>
<field name="model">hr.payslip.run</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_hr_payroll_private.report_dag_ausweis</field>
<field name="report_file">l10n_at_hr_payroll_private.report_dag_ausweis</field>
<field name="paperformat_id" ref="hr_payroll.paperformat_euro_light"/>
<field name="binding_model_id" ref="hr_payroll.model_hr_payslip_run"/>
<field name="binding_type">report</field>
</record>
<!-- All-in-Deckungsrechnung: GP2 `_at_allin_deckung()` als
Ausweis — wie viel der Pauschale von welchen Bezügen gedeckt
ist; Rest = Über-/Unterdeckung. -->
<template id="report_allin_deckung">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="slip">
<t t-call="web.external_layout">
<div class="page">
<h2>All-in-Deckungsrechnung</h2>
<table class="table table-sm">
<tr>
<td><strong>Mitarbeiter:in</strong></td>
<td><t t-out="slip.employee_id.name"/></td>
<td><strong>Periode</strong></td>
<td><t t-out="slip.date_from"/> <t t-out="slip.date_to"/></td>
</tr>
<tr>
<td><strong>All-in-Pauschale</strong></td>
<td>
<span t-out="slip.version_id.allin_pauchale"
t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
<td><strong>Grundlohn (KV)</strong></td>
<td>
<span t-out="slip.version_id.allin_grundlohn"
t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
</table>
<h3>Bezüge vs. Pauschale</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Bezug</th>
<th class="text-end">Betrag</th>
</tr>
</thead>
<tbody>
<tr t-foreach="slip.line_ids.filtered(lambda l: l.total and l.salary_rule_id.l10n_at_bemessung != 'neutral' and l.salary_rule_id.category_id.code == 'ATP_BRUTTO')" t-as="line">
<td><t t-out="line.name"/></td>
<td class="text-end">
<span t-out="line.total" t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<t t-set="deckung" t-value="slip._at_allin_deckung()"/>
<t t-if="deckung and deckung.get('rest') is not None">
<p class="h4 text-end">
<strong>Rest:</strong>
<span t-out="deckung['rest']"
t-options='{"widget": "monetary", "display_currency": slip.currency_id}'/>
<t t-if="deckung['rest'] > 0">
<span class="text-warning">(Überdeckung — Pauschale übersteigt die Bezüge)</span>
</t>
<t t-if="deckung['rest'] &lt; 0">
<span class="text-danger">(Unterdeckung — Bezüge übersteigen die Pauschale)</span>
</t>
</p>
</t>
</div>
</t>
</t>
</t>
</template>
<!-- DAG-Ausweis: § 1 DAG je Monat — Lohnsumme der Geringfügigen,
Schwelle, Abgabe. -->
<template id="report_dag_ausweis">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="run">
<t t-call="web.external_layout">
<div class="page">
<h2>DAG-Ausweis (§ 1 DAG) — <t t-out="run.name"/></h2>
<t t-set="dag" t-value="run.company_id._l10n_at_dag(run.slip_ids[:1].date_from if run.slip_ids else None)"/>
<table class="table table-sm">
<thead>
<tr>
<th>Kennzahl</th>
<th class="text-end">Wert</th>
</tr>
</thead>
<tbody>
<tr>
<td>Anzahl geringfügig Beschäftigte</td>
<td class="text-end"><t t-out="dag and dag.get('anzahl', 0) or 0"/></td>
</tr>
<tr>
<td>Lohnsumme (ohne SZ)</td>
<td class="text-end">
<span t-out="dag and dag.get('lohnsumme', 0.0) or 0.0"
t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/>
</td>
</tr>
<tr>
<td>Schwelle (1,5 × GFG)</td>
<td class="text-end">
<span t-out="dag and dag.get('schwelle', 0.0) or 0.0"
t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Dienstgeberabgabe (19,4 %)</strong></td>
<td class="text-end">
<strong><span t-out="dag and dag.get('abgabe', 0.0) or 0.0"
t-options='{"widget": "monetary", "display_currency": run.company_id.currency_id}'/></strong>
</td>
</tr>
</tbody>
</table>
<p class="small text-muted">
Voraussetzungen: mindestens 2 geringfügig Beschäftigte
und Lohnsumme über der Schwelle (1,5 × GFG).
</p>
</div>
</t>
</t>
</t>
</template>
</odoo>
@@ -11,4 +11,9 @@ access_l10n_at_payroll_kv_import_wizard,L10n AT Payroll KV-Import Wizard,model_l
access_l10n_at_payroll_kv_import_line,L10n AT Payroll KV-Import Line,model_l10n_at_payroll_kv_import_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_bv_kasse_user,L10n AT BV-Kasse User,model_l10n_at_bv_kasse,hr_payroll.group_hr_payroll_user,1,0,0,0
access_l10n_at_bv_kasse_manager,L10n AT BV-Kasse Manager,model_l10n_at_bv_kasse,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_bv_endabrechnung_wizard,L10n AT BV-Endabrechnung Wizard,model_l10n_at_bv_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_bv_endabrechnung_wizard,L10n AT BV-Endabrechnung Wizard,model_l10n_at_bv_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_mbgm_user,L10n AT Payroll mBGM User,model_l10n_at_payroll_mbgm,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_mbgm_manager,L10n AT Payroll mBGM Manager,model_l10n_at_payroll_mbgm,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_mbgm_line_user,L10n AT Payroll mBGM-Line User,model_l10n_at_payroll_mbgm_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_vr_user,L10n AT Payroll VR User,model_l10n_at_payroll_vr,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_vr_event_user,L10n AT Payroll VR-Event User,model_l10n_at_payroll_vr_event,hr_payroll.group_hr_payroll_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_l10n_at_payroll_kv_import_line L10n AT Payroll KV-Import Line model_l10n_at_payroll_kv_import_line hr_payroll.group_hr_payroll_user 1 1 1 1
12 access_l10n_at_bv_kasse_user L10n AT BV-Kasse User model_l10n_at_bv_kasse hr_payroll.group_hr_payroll_user 1 0 0 0
13 access_l10n_at_bv_kasse_manager L10n AT BV-Kasse Manager model_l10n_at_bv_kasse hr_payroll.group_hr_payroll_manager 1 1 1 1
14 access_l10n_at_bv_endabrechnung_wizard L10n AT BV-Endabrechnung Wizard model_l10n_at_bv_endabrechnung_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
15 access_l10n_at_payroll_mbgm_user L10n AT Payroll mBGM User model_l10n_at_payroll_mbgm hr_payroll.group_hr_payroll_user 1 1 1 0
16 access_l10n_at_payroll_mbgm_manager L10n AT Payroll mBGM Manager model_l10n_at_payroll_mbgm hr_payroll.group_hr_payroll_manager 1 1 1 1
17 access_l10n_at_payroll_mbgm_line_user L10n AT Payroll mBGM-Line User model_l10n_at_payroll_mbgm_line hr_payroll.group_hr_payroll_user 1 1 1 1
18 access_l10n_at_payroll_vr_user L10n AT Payroll VR User model_l10n_at_payroll_vr hr_payroll.group_hr_payroll_user 1 1 1 1
19 access_l10n_at_payroll_vr_event_user L10n AT Payroll VR-Event User model_l10n_at_payroll_vr_event hr_payroll.group_hr_payroll_user 1 1 1 1
@@ -4,4 +4,5 @@ from . import test_entgelt_private
from . import test_lst_private
from . import test_sv_private
from . import test_bv_private
from . import test_account_private
from . import test_account_private
from . import test_meldewesen_private
@@ -0,0 +1,183 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP7 privat: mBGM (Paket/mBGM/Tarifblock/Basis/Position-Hierarchie
aus GP4-Beitragssummen, T01-Gesamtsatz aus at_mbgm_tarif, Abschläge
A01/A02/A03 §-2a-AMPFG, A09/A10/A15, BV-Basis V01) und
Versichertenmeldung M3/M4 (deklarative Event-Erkennung,
Meldungs-Registry, AGRD)."""
import base64
import datetime
from odoo.exceptions import UserError
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class MeldewesenPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({
'l10n_at_beitragskontonummer': '1412345678',
'l10n_at_lohnsteuernummer': '123456789',
})
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT Test 40h',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 16.0})
for wd in range(5)
],
})
self.company.resource_calendar_id = self.calendar
employee = self.env['hr.employee'].create({
'name': 'mBGM 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',
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
self.employee = employee
self.version = version
def _slip(self, monat, jahr=2026, 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': 'mBGM %d/%d' % (monat, jahr),
'employee_id': self.employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
if confirm:
slip.action_payslip_done()
return slip
def _total(self, slip, code):
return sum(slip.line_ids.filtered(
lambda l: l.code == code).mapped('total'))
def test_01_mbgm_november(self):
"""November mit B13 (KV-Katalog 2.059,41): Paket PS mit
BZRM/GSUM/ANZM, mBGM G1 (VSNR, VSUM), Tarifblock T1 (BSGR
B002), Basen AB/SZ/BV mit Positionen T01 (39,30 % Gesamtsatz),
T02 (34,60 %), V01 (1,53 %) — Konsistenz gegen die
GP4-Beitragssummen."""
slip = self._slip(11)
deklaration = self.env['l10n.at.payroll.mbgm'].create({
'company_id': self.company.id,
'beitragsmonat': datetime.date(2026, 11, 1),
'art': 'MB',
})
deklaration.action_erzeugen()
self.assertEqual(deklaration.state, 'done')
self.assertEqual(len(deklaration.line_ids), 1)
self.assertEqual(deklaration.line_ids.bsgr, 'B002')
roh = base64.b64decode(deklaration.datei).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
# Dateiheader + Vorlauf + PS + G1 + T1 + 5×(BS+V1)
# (T01, A03, T02, A03-SZ, V01 BV) + Schluss: 13 Daten-Sätze
# + 3 Rahmen-Zeilen = 16.
self.assertEqual(len(zeilen), 16)
vorlauf = zeilen[1]
self.assertEqual(vorlauf[0:2], '00')
self.assertEqual(vorlauf[18:20], '14')
self.assertEqual(vorlauf[22:24], 'MB')
daten = zeilen[2:-1]
paket = daten[0]
self.assertEqual(paket[0:2], 'PS')
self.assertEqual(paket[60:70], '1412345678') # BKNR
self.assertEqual(paket[281:287], '112026') # BZRM MMJJJJ
self.assertEqual(paket[299:305], '000001') # ANZM
gsum = int(paket[288:299])
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())
self.assertLessEqual(abs(gsum / 100.0 - ist), 0.10)
satzarten = [zeile[0:2] for zeile in daten]
self.assertEqual(satzarten, ['PS', 'G1', 'T1', 'BS',
'V1', 'BS', 'V1', 'BS', 'V1',
'BS', 'V1', 'BS', 'V1'])
g1 = daten[1]
self.assertEqual(g1[140:150], '1234150590')
t1 = daten[2]
self.assertEqual(t1[20:24], 'B002')
v1_t01 = daten[4]
self.assertEqual(v1_t01[20:23], 'T01')
self.assertEqual(v1_t01[24:30], '039050') # 39,05 %
# §-2a-AMPFG-Abschlag A03 (BG 2.059,41 ≤ 2.225 → AV 0 %),
# auf AB und SZ je als eigene Position.
v1_a03 = daten[6]
self.assertEqual(v1_a03[20:23], 'A03')
self.assertEqual(v1_a03[30:31], '-') # RSVZ negativ
v1_t02 = daten[8]
self.assertEqual(v1_t02[20:23], 'T02')
self.assertEqual(v1_t02[24:30], '037550') # 37,55 %
v1_a03_sz = daten[10]
self.assertEqual(v1_a03_sz[20:23], 'A03')
def test_02_vr_an_abmeldung(self):
"""M3 (vor Arbeitsantritt) mit BBER/GERF/BVJN/VWAZ und M4
(7-Tage-Frist) mit AGRD aus dem Abgangsgrund-Mapping; die
Registry verhindert Doppelanmeldungen."""
lauf = self.env['l10n.at.payroll.vr'].create({
'company_id': self.company.id,
'stichtag': datetime.date(2026, 1, 31),
})
lauf.action_erzeugen()
roh = base64.b64decode(lauf.datei).decode('ISO-8859-15')
zeilen = roh.strip('\r\n').split('\r\n')
m3 = [z for z in zeilen if z[0:2] == 'M3']
self.assertEqual(len(m3), 1)
self.assertEqual(m3[0][100:110], '1412345678') # BKNR
self.assertEqual(m3[0][314:324], '1234150590') # VSNR
self.assertEqual(m3[0][324:332], '15051990') # GEBD TTMMJJJJ
self.assertEqual(m3[0][512:520], '01012020') # ADAT
self.assertEqual(m3[0][536:538], '02') # BBER Angestellte
self.assertEqual(m3[0][538:539], 'N') # GERF
self.assertEqual(m3[0][539:540], 'N') # FRDV
self.assertEqual(m3[0][768:772], '4000') # VWAZ 40 h
self.assertEqual(m3[0][618:619], 'J') # BVJN (GP5)
# Registry verhindert Doppelanmeldung; Abmeldung nach Ende.
zweiter = self.env['l10n.at.payroll.vr'].create({
'company_id': self.company.id,
'stichtag': datetime.date(2026, 2, 28),
})
with self.assertRaises(UserError):
zweiter.action_erzeugen()
self.version.write({
'contract_date_end': datetime.date(2026, 2, 15),
'departure_reason_id': self.env['hr.departure.reason']
.search([], limit=1).id,
})
dritter = self.env['l10n.at.payroll.vr'].create({
'company_id': self.company.id,
'stichtag': datetime.date(2026, 3, 31),
})
dritter.action_erzeugen()
roh = base64.b64decode(dritter.datei).decode('ISO-8859-15')
m4 = [z for z in roh.strip('\r\n').split('\r\n')
if z[0:2] == 'M4']
self.assertEqual(len(m4), 1)
self.assertEqual(m4[0][512:520], '15022026') # ADAT
self.assertEqual(m4[0][548:550], '00') # AGRD Default
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP7 Meldewesen (privat): mBGM + Versichertenmeldung. -->
<!-- ============================================================ -->
<record id="mbgm_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mbgm.list</field>
<field name="model">l10n.at.payroll.mbgm</field>
<field name="arch" type="xml">
<list string="mBGM (JASV)">
<field name="beitragsmonat"/>
<field name="art"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-info="state == 'draft'"/>
</list>
</field>
</record>
<record id="mbgm_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mbgm.form</field>
<field name="model">l10n.at.payroll.mbgm</field>
<field name="arch" type="xml">
<form string="Monatliche Beitragsgrundlagenmeldung (mBGM)">
<header>
<button name="action_erzeugen" type="object"
string="Lauf erzeugen (Datei)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="beitragsmonat"/>
<field name="art"/>
<field name="storno_id"
invisible="art != 'ST'"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="mBGM-Sätze" name="linien">
<field name="line_ids">
<list editable="bottom">
<field name="employee_name"/>
<field name="versicherungsnummer"/>
<field name="bsgr"/>
<field name="refw"/>
<field name="vsum"/>
</list>
</field>
</page>
<page string="Datei &amp; Protokoll" name="datei">
<group>
<field name="datei"
filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_mbgm" model="ir.actions.act_window">
<field name="name">mBGM (JASV)</field>
<field name="res_model">l10n.at.payroll.mbgm</field>
<field name="view_mode">list,form</field>
</record>
<record id="vr_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.vr.form</field>
<field name="model">l10n.at.payroll.vr</field>
<field name="arch" type="xml">
<form string="Versichertenmeldung (An-/Abmeldung)">
<header>
<button name="action_erzeugen" type="object"
string="Meldungen erzeugen (Datei)"
class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="stichtag"/>
<field name="nur_fallweise"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Datei &amp; Protokoll" name="datei">
<group>
<field name="datei"
filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
<field name="protokoll"/>
<field name="event_ids">
<list>
<field name="employee_id"/>
<field name="art"/>
<field name="adatum"/>
<field name="refw"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_vr" model="ir.actions.act_window">
<field name="name">Versichertenmeldung (An-/Abmeldung)</field>
<field name="res_model">l10n.at.payroll.vr</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_l10n_at_payroll_private_meldewesen"
name="Meldewesen (ELDA/eSV)"
parent="l10n_at_hr_payroll_private.menu_l10n_at_payroll_private_root"
sequence="70"/>
<menuitem id="menu_l10n_at_payroll_private_mbgm"
name="mBGM (JASV)"
parent="menu_l10n_at_payroll_private_meldewesen"
action="action_mbgm" sequence="10"/>
<menuitem id="menu_l10n_at_payroll_private_vr"
name="Versichertenmeldung"
parent="menu_l10n_at_payroll_private_meldewesen"
action="action_vr" sequence="20"/>
</odoo>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP8: UI der GP4-Vertragsstichtage + GP5/GP6-Felder am -->
<!-- Mitarbeiterformular (Payroll-Gruppe, Muster KV-Einstufung). -->
<!-- ============================================================ -->
<record id="hr_employee_view_form_stichtage" model="ir.ui.view">
<field name="name">hr.employee.form.l10n_at_stichtage</field>
<field name="model">hr.employee</field>
<field name="inherit_id"
ref="l10n_at_hr_payroll_private.payroll_hr_employee_view_form_bv"/>
<field name="arch" type="xml">
<group name="bv_einstufung" position="after">
<group name="stichtage" string="SV-Stichtage &amp; Status (GP4)"
groups="hr_payroll.group_hr_payroll_user">
<group string="Satzausschaltungen">
<field name="l10n_at_alv_entfall_ab"/>
<field name="l10n_at_uv_entfall_ab"/>
<field name="l10n_at_pv_bonusphase_ab"/>
<field name="l10n_at_lnkp_frei_ab"/>
</group>
<group string="Status &amp; Abrechnung">
<field name="l10n_at_lehrling"/>
<field name="l10n_at_fallweise"/>
<field name="l10n_at_familienabsetzbetrag"/>
<field name="l10n_at_ueberw_nachtarbeit"/>
</group>
</group>
</group>
</field>
</record>
</odoo>
+57
View File
@@ -1,5 +1,62 @@
# CHANGELOG — odoo-at-payroll
## 2026-09-12 — General-AT GP7: Meldewesen/Belege umgesetzt (M7; Kern-Bau AP5/AP6)
- `addons/l10n_at_hr_payroll` (19.0.4.0.0): **ELDA-Fixsatz-Framework**
`models/elda.py` (DM-ORG 42.7.0: Fixlängen ohne Trennzeichen, a/n
linksbündig-blank / n rechtsbündig-0, Cent ohne Interpunktion,
Prozente ×1000, ISO-8859-15, Dateiheader optional, Vorlauf SART 00/
Schluss SART 99 mit SANR-Kette ab 1 und SANZ inkl. Rahmen;
**CRLF als Satztrenner = Praxis-Annahme** — DM-ORG normiert keines).
**Lohnzettel Finanz L16 + Arbeitsstättenmeldung § 34 Abs 6 ASVG**
`models/lohnzettel_l16.py`: I1 (1.100) + L1 (**3.500**,
Lohnzettelversion 28, BEST LF/VSTR 94) inkl. Kinderblock 15×123
ab Pos. 1501 — PDF-verifizierte Positions-Ketten; **KZ 210 =
Bruttobezüge gem. § 25 gesamt** (KZ 245 = 210215220230243 =
laufende Steuerbemessung, konsistent mit der GP3-§-66-Kette per
Konstruktion; Ableitung aus l10n_at_bemessung/steuerbefreiung_68/
s67_abs, KEIN paralleles KZ-Feld). Arbeitsstätte: SART 45 (300,
BEST AD/VSTR ST) je AN am 31. 12. aus dem Dienstort-Partner
(GKZ manuell, Warnung wenn fehlt).
**Kind-Datenmodell** `models/kind.py` (Nutzer-Entscheidung: FB+
rechnet NICHT im Lohnsteuerkern — Steuer < 0 → Arbeitnehmer-
veranlagung; Modell dient der FABO+-Wartung: Monate voll/halb,
Alterstaffel, L16-Kinderblock). **Stammdaten** `models/meldewesen.py`:
BKNR + Lohnsteuernummer (Company), GKZ (res.partner), VSNR-Format-
prüfung LLLPTTMMJJ auf hr.version.ssnid (Prüfziffer = Rest, SVS-
Quelle), eSV-AGRD-Mapping auf hr.departure.reason. **Belege**
(AP6): Lohnzettel AT (ir.actions.report auf hr.payslip) +
Monatsdienstliste + Monatsjournal (QWeb auf hr.payslip.run,
AbstractModel `models/monatsreports.py`; kein HR-Modul nutzt
account.report).
- `addons/l10n_at_hr_payroll_private` (19.0.7.0.0): **mBGM/JASV**
(BEST MB, Version 02; `models/mbgm.py`): Paket PS/mBGM G1/Tarif-
block T1/Verrechnungsbasis BS/Position V1 aus GP4-Beitragsmonats-
Daten; T01/T02-GESAMT-Prozentsatz je BSGR aus `at_mbgm_tarif`
(normativ laufend **39,05 %**, SZ **37,55 %** = DN KV 3,87 + PV
10,25 + AV 2,95 + DG 20,48); **Abschläge je Verrechnungsbasis**
(A03/A02/A01 §-2a-AMPFG DN-AV auf AB UND SZ, A09 UV-60, A10
AV+IE-Entfall, A15 PV-Halbierung; Konkurrenz a4: A10 sperrt
Staffelabschlag); BV-Basis (ungedeckelt, GP5) + V01; RSUM =
Basis×Satz mit Konsistenz-Check gegen GP4-Payslip-Summen (±0,10 €);
Storno R1 (REFU/VSUM wie Original), Fehlanzeige als Null-Paket;
Geringfügige/fallweise ohne Parametrisierung → listenweiser
Abbruch (keine Tarif-Erfindung). **Versichertenmeldung**
`models/versichertenmeldung.py` (BEST VR, Version 03): M3 vor
Arbeitsantritt / M4 (7 Tage) mit deklarativer Event-Erkennung über
die Meldungs-Registry (idempotente Läufe, kein write-diff), BBER
0104, GERF (Gehalt vs. GFG), FRDV, BVAB/BVJN aus GP5, VWAZ
Stunden×100, AGRD aus dem Abgangsgrund-Mapping; **fallweise
Anmeldung** (BEST MA, SART 32, Version 07; MVP je Anmeldetag).
NSchAB-Branche als Company-Flag (B001-T01 + 3,80).
- **Bewusst nicht in GP7:** Schwerarbeitsmeldung (DM-ORG E.22-
Struktur vorhanden, aber keine ASchV-Datenbasis), § 109a (separate
Honorar-Datenbasis), L17 (keine AG-Pflicht), Mitteilungsfile-Import
(Nutzer-Entscheidung 5.2 → GP8), FB+-Lohnsteuermechanik (ANV).
- Tests: `test_elda_l16` (4) + `test_meldewesen_private` (2) —
**94/94 grün** (frischer `-i`-Lauf, Wegwerf-DB). Rechenfälle
`docs/rechenfaelle/meldewesen.md`.
## 2026-09-12 — General-AT GP6: FI-Buchung UGB nach KFS/BW 6 umgesetzt (M6)
- `addons/l10n_at_hr_payroll_private` (19.0.6.0.0): **GP6 FI-Buchung
@@ -783,6 +783,34 @@ Budget: die Kandidaten 8.13, der DZ (8.11) und die Regel-Optionen
(NSchAB, DAG) laufen über die GP0-/Pilotmandant-Entscheidung bzw.
Change-Requests.
**Update 2026-09-12 (GP7 umgesetzt):** Meldewesen/Belege komplett
umgesetzt und grün (94/94) — Nutzer-Entscheidungen 5.1-5.4 (2026-
09-12): Arbeitsstättenmeldung § 34 Abs 6 in GP7 (SART 45, BEST AD/
VSTR ST — im selben Jahreslauf wie die L16); Mitteilungsfile-Import
= GP8-Rest; **FB+ rechnet NICHT im Lohnsteuerkern** (Steuer < 0 →
Arbeitnehmerveranlagung — das Kind-Modell dient der FABO+-Wartung
und dem L16-Kinderblock); alles in einem GP7. Kern 19.0.4.0.0:
ELDA-Fixsatz-Framework (`models/elda.py`), L16 (I1 1.100 + L1
3.500, Lohnzettelversion 28, BEST LF/VSTR 94; **KZ 210 = § 25-
Gesamtbrutto** — KZ-245-Formel damit die laufende Steuerbemessung,
per Konstruktion GP3-konsistent; Ableitung aus l10n_at_bemessung/
steuerbefreiung_68/s67_abs, kein paralleles KZ-Feld), Arbeitsstätte,
Kind-Modell, Stammdaten (BKNR/Lohnsteuernummer/GKZ/VSNR-Pruefung/
AGRD-Mapping), 3 Belege (Lohnzettel AT/Monatsdienstliste/Monats-
journal, QWeb auf hr.payslip bzw. hr.payslip.run — account.report
in keinem HR-Modul). Privat 19.0.7.0.0: mBGM/JASV (PS/G1/T1/BS/V1,
T01/T02-Gesamtsatz aus `at_mbgm_tarif` — normativ 39,05 % laufend/
37,55 % SZ; **Abschläge je Basis AB UND SZ** A03/A02/A01 §-2a-DN-AV,
A09/A10/A15; BV-Basis + V01 aus GP5; RSUM=Basis×Satz mit GP4-
Konsistenzcheck; Storno R1/REFU; Fehlanzeige Null-Paket), Ver-
sichertenmeldung M3/M4 (deklarative Event-Erkennung über die
Meldungs-Registry — kein write-diff; BBER/GERF/FRDV/BVAB/BVJN/VWAZ/
AGRD), fallweise Anmeldung BEST MA/SART 32; NSchAB-Company-Flag.
Bewusst nicht: Schwerarbeitsmeldung (DM-ORG E.22-Struktur vorhanden,
keine ASchV-Datenbasis), § 109a (Honorar-Basis), L17 (keine AG-
Pflicht), geringfügig-mBGM bis Parametrisierung (keine Tarif-
erfindung). Rechenfälle `docs/rechenfaelle/meldewesen.md`.
**Update 2026-09-12 (GP6 umgesetzt):** FI-Buchung UGB nach dem
Österreichischen Einheitskontenrahmen (KFS/BW 6 — Nutzer-Referenz-
Exemplar `.additional_docs/KFSBW6_13072017_RF.pdf`) umgesetzt und