[IMP] l10n_at_hr_payroll_private: Arbeits- und Entgeltbestätigungen BEST AK/AW — SART 70/71 Kranken-/75/76 Wochengeld (AP8)

Satzstrukturen PDF-verifiziert aus DM-ORG 42.7.0 (ODL, Pages
493-505; Positionsketten lückenlos): G.10 AEB-Krankengeld v12
(SART 70/71, BEST AK, 111 Felder, Satzlänge 1392) und G.11
AEB-Wochengeld v10 (SART 75/76, BEST AW, 64 Felder, 1048).
Die .txt-Extraktion des DM-ORG ist unzuverlässig (Feldversatz).

- Neues Modell l10n.at.payroll.aeb (models/aeb.py): Episode
  (verhinderung_von/bis), KZKU, Storno über storno_id (E.10.2.1:
  nur Fehler/BMGL-Änderung), REFN/Datei/Prüfprotokoll; Erzeugung
  auf dem ELDA-Bestands-Framework (Vorlauf/Schluss/Satzlängen-
  selbstkontrolle)
- EFZ-Refaktur: Phasen-Karte nach hr.version verschoben
  (_l10n_at_efz_arbeitsjahr_start/_l10n_at_efz_phasen/
  _l10n_at_efz_stufe_am) — Payslip-Status (AP4) und AEB teilen die
  Maschine; _at_efz_status-Verhalten durch Tests gepinnt
- Fachdaten Krankengeld: AEFZ (Anspruch in Wochen), JAGU=1
  (Arbeitsjahr), TAGU=2 (Kalendertage), Teilentgelt TPR/TVO/TBI
  je Phase, Vorerkrankungen ANV/ANB vor der Episode, VENT
  (Voll-Phase-Ende), Bezugsblöcke BVO/BBI/BBE + BSUM aus den
  vollen Monatsläufen vor der Episode, SZKZ datengetrieben
- Fachdaten Wochengeld: AVER (Ø-Netto der drei Referenzmonate,
  § 162 Abs 3 ASVG — AP7-Logik), AVON/ABIS, ANGV=K (kein
  Fortbezug), AGRD-Validierung nach D.22 (SART 75/76 nur
  02/03/04/05/06/10/22/24; unzulässige geleert + Protokoll)
- Fund: L10nAtEldaSatz.setze(None) übernimmt die Grundstellung
  nicht — Felder nur per Leerstring aktiv leeren
- 4 Tests: SART 70 mit 76-Tage-Episode (AEFZ 06, TPR1 50,
  TVO1 13.10.2026, VENT 12.10.2026, Bezugsblock), SART 75 mit
  AVER/AVON/ABIS, Storno 76 (REFN -ST, Kerndaten des Originals),
  AGRD-Validierung
This commit is contained in:
2026-09-12 23:52:11 +02:00
parent 802f2ef04c
commit c3b9d6eb8e
9 changed files with 1085 additions and 110 deletions
+60
View File
@@ -2,6 +2,60 @@
## Current focus ## Current focus
**AP 8 Arbeits-/Entgeltbestätigungen umgesetzt — 152/152 Tests
grün — damit ist der AP0AP10-Nutzer-Katalog komplett** (2026-09-12,
AEB-Session; siehe auch Parallel-Session-Notiz unten):
- **Satzstrukturen PDF-verifiziert** (DM-ORG 42.7.0 lokal, ODL
Pages 493-505): **G.10** AEB-Krankengeld v12 (SART 70/71, BEST
AK, 111 Felder, Satzlänge 1392), **G.11** AEB-Wochengeld v10
(SART 75/76, BEST AW, 64 Felder, 1048) — Positionsketten
lückenlos geprüft; die .txt-Extraktion des DM-ORG ist
UNZUVERLÄSSIG (Feldversatz) — PDF-Extraktion verbindlich.
- **Model** `l10n.at.payroll.aeb` (models/aeb.py): art, Episode
(verhinderung_von/bis), KZKU, Storno via storno_id (E.10.2.1:
nur Fehler/BMGL-Änderung), REFN/Datei/Prüfprotokoll; ELDA-
Bestände AK/AW. Fachdaten: Ident/Wohnort (version.private_*),
Bank (primary_bank_account_id), EFZ-Anspruch AEFZ/JAGU=1/TAGU=2,
Teilentgelt TPR/TVO/TBI, Vorerkrankungen ANV/ANB, VENT,
Bezugsblöcke BVO/BBI/BBE+BSUM; Wochengeld: AVER (Ø-Netto
3 Referenzmonate § 162 Abs 3), AVON/ABIS, ANGV=K,
**AGRD-Validierung D.22** (SART 75/76 nur 02/03/04/05/06/10/22/24
— unzulässige geleert + Protokoll).
- **EFZ-Refaktur:** Phasen-Karte nach **hr.version** verschoben
(`_l10n_at_efz_arbeitsjahr_start`/`_l10n_at_efz_phasen`/
`_l10n_at_efz_stufe_am`) — Payslip-Status und AEB teilen die
Maschine; AP4-Tests unverändert grün. Fund: `setze(None)`
übernimmt Grundstellung NICHT — löschen nur über Leerstring.
- 4 Tests (SART 70 mit 76-Tage-Episode: AEFZ 06, TPR1 50,
TVO1 13.10., VENT 12.10.; SART 75 mit AVER; Storno 76;
AGRD-Validierung). Version **19.0.9.4.0**.
- **Offen (MVP-Reste):** Prüfkatalog-Verifikation (Parallellauf,
P7), KE/UE-Zeilen, EIN/APFT-Feiertage, VOAB/VOBI, Prämien-/
Provisionsausweis (seit-2025-Felder). **Abstimmung mit der
Parallel-Session: PLAN-Restluecken AP11+ unabhängig startbar,
AEB nicht mehr als Restlücke.**
**Gap-Reevaluierung + Restlücken-Plan AP11 ff. freigegeben und
versioniert** (2026-09-12): Cluster-Abgleich Wissensbasis (601
Einträge/69 Cluster) gegen den AP0AP10-Stand neu aufgezogen —
die Vorphase hat die große Gap-Liste weitgehend geschlossen
(EFZG AP4, Wochengeld AP7, Wr. DAG AP3, BSGR/ERGB AP0, Reports
AP2, Abwesenheits-WETs AP5, VWGJ AP9, GemBG-Fixes AP10).
Verblieben: Pfändung, Sachbezüge, Reisekosten/Pendler, End-
abrechnungs-Zyklus (Urlaubsersatzleistung/Vergleich/Aufrechnung),
Abfertigung Alt, Karenz-Reste, L17/§ 109a, Schwerarbeitsmeldung,
Status-/Verhinderungs-Sonderfälle; Optionen ATZ/Kurzarbeit/
Beteiligung/Entsendung. Plan **freigegeben (Nutzer)** und persistiert:
`personalverrechnung/PLAN-Restluecken-AP11.md` — Pakete AP11
Pfändung · AP12 Sachbezüge · AP13 Reise/Pendler · AP14 End-
abrechnung · AP15 Abf Alt · AP16 Karenz-Reste (Scope-Gate 8.13) ·
AP17 Meldewesen-Reste · AP18 Schwerarbeit (Beschaffungsgate) ·
AP19 Status-Sonderfälle; Σ AP11AP19 ≈ 90142 PT; je Paket RIS-
Gate vor Bau (Werte nur aus Quelle). **AP8 (AEB) läuft parallel in
eigener Session** — Plan führt es nur als Restabgleich; keine
Doppelumsetzung, Paket-Starts mit der Parallel-Session abstimmen.
**AP 7 Mutterschutz/Wochengeld umgesetzt — 148/148 Tests grün** **AP 7 Mutterschutz/Wochengeld umgesetzt — 148/148 Tests grün**
(2026-09-12): (2026-09-12):
@@ -1446,6 +1500,12 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“).
## Files that matter right now ## Files that matter right now
- `personalverrechnung/PLAN-Restluecken-AP11.md`**freigegebener**
Ausbauplan Restlücken Wissensbasis↔Modul (AP11 Pfändung, AP12
Sachbezüge, AP13 Reise/Pendler, AP14 Endabrechnung, AP15 Abf Alt,
AP16 Karenz-Reste, AP17 L17/§ 109a, AP18 Schwerarbeit, AP19
Status-Sonderfälle, AP20+ Optionen; AP8 parallel; Reihenfolge,
Gates, PT-Summen; Status je Paket nachziehen)
- `.firecrawl/ris/` — Primärquellen-Downloads (19 RIS-Gesetzestexte, - `.firecrawl/ris/` — Primärquellen-Downloads (19 RIS-Gesetzestexte,
LStR-2002-XML, DM-ORG 42.7.0 + Prüfkataloge + XSDs, SV-Werte 2026; LStR-2002-XML, DM-ORG 42.7.0 + Prüfkataloge + XSDs, SV-Werte 2026;
Verzeichnis `_README.md` mit GesNr und Kurztitel-Fallen; lokal, Verzeichnis `_README.md` mit GesNr und Kurztitel-Fallen; lokal,
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3. # Part of the odoo-at-payroll project. License: LGPL-3.
{ {
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
'version': '19.0.9.3.0', 'version': '19.0.9.4.0',
'author': 'Florian Egger', 'author': 'Florian Egger',
'website': 'https://gem360.at', 'website': 'https://gem360.at',
'category': 'Human Resources/Payroll', 'category': 'Human Resources/Payroll',
@@ -110,6 +110,7 @@ gem360-Brücke).
'data/salary_rules_msch_private.xml', 'data/salary_rules_msch_private.xml',
'data/salary_rules_sv_private.xml', 'data/salary_rules_sv_private.xml',
'data/salary_rules_bvg_private.xml', 'data/salary_rules_bvg_private.xml',
'data/salary_rules_bvg_private.xml',
'data/hr_payroll_account_private.xml', 'data/hr_payroll_account_private.xml',
'data/rule_parameters_mbgm_private.xml', 'data/rule_parameters_mbgm_private.xml',
'data/rule_parameters_wien_private.xml', 'data/rule_parameters_wien_private.xml',
@@ -121,6 +122,7 @@ gem360-Brücke).
'views/meldewesen_views.xml', 'views/meldewesen_views.xml',
'views/vertragsfelder_views.xml', 'views/vertragsfelder_views.xml',
'views/mitarbeiter_views.xml', 'views/mitarbeiter_views.xml',
'views/aeb_views.xml',
'report/gp8_reports.xml', 'report/gp8_reports.xml',
'report/buchungsbeleg_report.xml', 'report/buchungsbeleg_report.xml',
'report/bv_report.xml', 'report/bv_report.xml',
@@ -13,4 +13,5 @@ from . import fristen_private
from . import buchungsbeleg from . import buchungsbeleg
from . import wien_dag from . import wien_dag
from . import efzg from . import efzg
from . import mutterschutz from . import mutterschutz
from . import aeb
@@ -0,0 +1,561 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP8: Arbeits- und Entgeltbestätigungen (AEB) — ELDA-Bestände
``AK`` (Krankengeld, SART 70/71, Version 12) und ``AW`` (Wochengeld,
SART 75/76, Version 10), DM-ORG 42.7.0 Kapitel E.10/E.11 + G.10/G.11
(Satzstrukturen PDF-verifiziert aus der lokalen DM-ORG-Extraktion
2026-09-12; Positionsketten lückenlos geprüft: Krankengeld 111 Felder
bis Stelle 1392, Wochengeld 64 Felder bis Stelle 1048).
Fachliche Rollen (lb-krs-02 / lb-msf-03): Krankengeld und Wochengeld
zahlt die KV; die AEB ist die Bemessungsdaten-Lieferung des
Dienstgebers nach Ausschöpfung des vollen EFZ-Anspruchs (Krankengeld)
bzw. bei Mutterschutz-Frist (Wochengeld; AGRD dort nur 02/03/04/05/06/
10/22/24, D.22). Storno (E.10.2.1) nur bei Fehler oder Änderung der
Bemessungsgrundlage — sonst Korrektur per Neumeldung.
Datenquellen: EFZ-Phasen-Karte (``hr.version._l10n_at_efz_phasen``,
AP4/AP8-Refaktur) für Anspruch (AEFZ/JAGU/TAGU), Teilentgelt-Phasen
(TPR/TVO/TBI), Vorerkrankungen (ANV/ANB) und volles-Entgelt-Ende
(VENT); Payslip-Historie für die Bezugsblöcke (BVO/BBI/BBE) bzw. den
Wochengeld-Arbeitsverdienst (AVER = Ø-Netto der drei Referenzmonate,
§ 162 Abs 3 ASVG — AP7-Logik).
Grundstellung ohne Modell (dokumentierter MVP-Rest): Kündigungs-
entschädigung/Urlaubsersatzleistung, Einarbeitungs-/Feiertagsfelder,
Vordienstzeiten (§ 2 Abs 3 EFZG), Prämien-/Provisionsausweis —
Prüfkatalog-Verifikation im Parallellauf (Prämisse P7)."""
import base64
from datetime import timedelta
from odoo import 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
# ------------------------------------------------------------------
# G.10 Arbeits- und Entgeltbestätigung für Krankengeld Version 12
# (SART 70/71) — nach IDTEIL, Satzlänge 1392.
# ------------------------------------------------------------------
AEB_KRANKENGELD_SATZ = [
(2, 'BKNR', 21, 10, 'a'),
(3, 'DGNA', 31, 70, 'a'),
(4, 'DTEL', 101, 50, 'a'),
(5, 'WOBD', 151, 12, 'a'),
(6, 'ZOBD', 163, 15, 'a'),
(7, 'VSNR', 178, 10, 'n'),
(8, 'GEBD', 188, 8, 'n'),
(9, 'FANA', 196, 70, 'a'),
(10, 'VONA', 266, 70, 'a'),
(11, 'AKGR', 336, 30, 'a'),
(12, 'WFKZ', 366, 3, 'a'),
(13, 'PLZL', 369, 9, 'a'),
(14, 'WORT', 378, 40, 'a'),
(15, 'STRA', 418, 50, 'a'),
(16, 'BEAB', 468, 8, 'n'),
(17, 'KABE', 476, 1, 'n'),
(18, 'TAET', 477, 30, 'a'),
(19, 'BTAG', 507, 1, 'n'),
(20, 'TATU', 508, 1, 'a'),
(21, 'LTAG', 509, 8, 'n'),
(22, 'GRUN', 517, 30, 'a'),
(23, 'KZKU', 547, 1, 'a'),
(24, 'ARFT', 548, 7, 'a'),
(25, 'ARTX', 555, 25, 'a'),
(26, 'BLOE', 580, 1, 'a'),
(27, 'EBSV', 581, 8, 'n'),
(28, 'IBAN', 589, 34, 'a'),
(29, 'BIC', 623, 11, 'a'),
(30, 'BVO1', 634, 8, 'n'),
(31, 'BBI1', 642, 8, 'n'),
(32, 'BBE1', 650, 9, 'n'),
(33, 'BVO2', 659, 8, 'n'),
(34, 'BBI2', 667, 8, 'n'),
(35, 'BBE2', 675, 9, 'n'),
(36, 'BVO3', 684, 8, 'n'),
(37, 'BBI3', 692, 8, 'n'),
(38, 'BBE3', 700, 9, 'n'),
(39, 'BVO4', 709, 8, 'n'),
(40, 'BBI4', 717, 8, 'n'),
(41, 'BBE4', 725, 9, 'n'),
(42, 'BSUM', 734, 9, 'n'),
(43, 'SZKZ', 743, 1, 'a'),
(44, 'SBGB', 744, 1, 'a'),
(45, 'SBZT', 745, 24, 'a'),
(46, 'KEAB', 769, 8, 'n'),
(47, 'KEBI', 777, 8, 'n'),
(48, 'UEAB', 785, 8, 'n'),
(49, 'UEBI', 793, 8, 'n'),
(50, 'VENT', 801, 8, 'n'),
(51, 'AEFZ', 809, 2, 'n'),
(52, 'JAGU', 811, 1, 'n'),
(53, 'TAGU', 812, 1, 'n'),
(54, 'TPR1', 813, 2, 'n'),
(55, 'TVO1', 815, 8, 'n'),
(56, 'TBI1', 823, 8, 'n'),
(57, 'TPR2', 831, 2, 'n'),
(58, 'TVO2', 833, 8, 'n'),
(59, 'TBI2', 841, 8, 'n'),
(60, 'ANV1', 849, 8, 'n'),
(61, 'ANB1', 857, 8, 'n'),
(62, 'ANV2', 865, 8, 'n'),
(63, 'ANB2', 873, 8, 'n'),
(64, 'ANV3', 881, 8, 'n'),
(65, 'ANB3', 889, 8, 'n'),
(66, 'ANV4', 897, 8, 'n'),
(67, 'ANB4', 905, 8, 'n'),
(68, 'ANV5', 913, 8, 'n'),
(69, 'ANB5', 921, 8, 'n'),
(70, 'ANV6', 929, 8, 'n'),
(71, 'ANB6', 937, 8, 'n'),
(72, 'FAN2', 945, 70, 'a'),
(73, 'VON2', 1015, 70, 'a'),
(74, 'AKG2', 1085, 30, 'a'),
(75, 'AGRD', 1115, 2, 'a'),
(76, 'VOAB', 1117, 8, 'n'),
(77, 'VOBI', 1125, 8, 'n'),
(78, 'FEAB', 1133, 8, 'n'),
(79, 'FEBI', 1141, 8, 'n'),
(80, 'EIN1', 1149, 8, 'n'),
(81, 'EIN2', 1157, 8, 'n'),
(82, 'EIN3', 1165, 8, 'n'),
(83, 'EIN4', 1173, 8, 'n'),
(84, 'EIN5', 1181, 8, 'n'),
(85, 'EIN6', 1189, 8, 'n'),
(86, 'REFN', 1197, 30, 'a'),
(87, 'APFT1', 1227, 8, 'n'),
(88, 'APFT2', 1235, 8, 'n'),
(89, 'APFT3', 1243, 8, 'n'),
(90, 'APFT4', 1251, 8, 'n'),
(91, 'APFT5', 1259, 8, 'n'),
(92, 'APFT6', 1267, 8, 'n'),
(93, 'APFT7', 1275, 8, 'n'),
(94, 'APFT8', 1283, 8, 'n'),
(95, 'APFT9', 1291, 8, 'n'),
(96, 'APFT10', 1299, 8, 'n'),
(97, 'ANV7', 1307, 8, 'n'),
(98, 'ANB7', 1315, 8, 'n'),
(99, 'ANV8', 1323, 8, 'n'),
(100, 'ANB8', 1331, 8, 'n'),
(101, 'ANV9', 1339, 8, 'n'),
(102, 'ANB9', 1347, 8, 'n'),
(103, 'ANV10', 1355, 8, 'n'),
(104, 'ANB10', 1363, 8, 'n'),
(105, 'GEGC', 1371, 2, 'a'),
(106, 'GEGT', 1373, 8, 'a'),
(107, 'ARLO', 1381, 1, 'a'),
(108, 'SZUM', 1382, 1, 'a'),
(109, 'SBUM', 1383, 1, 'a'),
(110, 'SBKZ', 1384, 1, 'a'),
(111, 'ALBV', 1385, 8, 'n'),
]
# ------------------------------------------------------------------
# G.11 Arbeits- und Entgeltbestätigung für Wochengeld Version 10
# (SART 75/76) — nach IDTEIL, Satzlänge 1048.
# ------------------------------------------------------------------
AEB_WOCHENGELD_SATZ = [
(2, 'BKNR', 21, 10, 'a'),
(3, 'DGNA', 31, 70, 'a'),
(4, 'DTEL', 101, 50, 'a'),
(5, 'WOBD', 151, 12, 'a'),
(6, 'ZOBD', 163, 15, 'a'),
(7, 'VSNR', 178, 10, 'n'),
(8, 'GEBD', 188, 8, 'n'),
(9, 'FANA', 196, 70, 'a'),
(10, 'VONA', 266, 70, 'a'),
(11, 'AKGR', 336, 30, 'a'),
(12, 'WKFZ', 366, 3, 'a'),
(13, 'PLZL', 369, 9, 'a'),
(14, 'WORT', 378, 40, 'a'),
(15, 'STRA', 418, 50, 'a'),
(16, 'BEAB', 468, 8, 'n'),
(17, 'KABE', 476, 1, 'n'),
(18, 'TAET', 477, 30, 'a'),
(19, 'BTAG', 507, 1, 'n'),
(20, 'TATU', 508, 1, 'a'),
(21, 'LTAG', 509, 8, 'n'),
(22, 'GRUN', 517, 30, 'a'),
(23, 'BLOE', 547, 1, 'a'),
(24, 'AGRD', 548, 2, 'a'),
(25, 'EBSV', 550, 8, 'n'),
(26, 'RESE', 558, 8, 'a'),
(27, 'PRAB', 566, 8, 'n'),
(28, 'URLV', 574, 8, 'n'),
(29, 'URLB', 582, 8, 'n'),
(30, 'IBAN', 590, 34, 'a'),
(31, 'BIC', 624, 11, 'a'),
(32, 'AVER', 635, 9, 'n'),
(33, 'AVON', 644, 8, 'n'),
(34, 'ABIS', 652, 8, 'n'),
(35, 'SBZT', 660, 24, 'a'),
(36, 'UNV1', 684, 8, 'n'),
(37, 'UNB1', 692, 8, 'n'),
(38, 'UNV2', 700, 8, 'n'),
(39, 'UNB2', 708, 8, 'n'),
(40, 'UNV3', 716, 8, 'n'),
(41, 'UNB3', 724, 8, 'n'),
(42, 'UNV4', 732, 8, 'n'),
(43, 'UNB4', 740, 8, 'n'),
(44, 'SZKZ', 748, 1, 'a'),
(45, 'ANGV', 749, 1, 'a'),
(46, 'AHEB', 750, 8, 'n'),
(47, 'AMEB', 758, 8, 'n'),
(48, 'FAN2', 766, 70, 'a'),
(49, 'VON2', 836, 70, 'a'),
(50, 'AKG2', 906, 30, 'a'),
(51, 'SZMO', 936, 4, 'n'),
(52, 'SZWO', 940, 4, 'n'),
(53, 'SBZW', 944, 1, 'a'),
(54, 'SBZE', 945, 1, 'a'),
(55, 'UNV5', 946, 8, 'n'),
(56, 'UNB5', 954, 8, 'n'),
(57, 'UNV6', 962, 8, 'n'),
(58, 'UNB6', 970, 8, 'n'),
(59, 'UNV7', 978, 8, 'n'),
(60, 'UNB7', 986, 8, 'n'),
(61, 'UNV8', 994, 8, 'n'),
(62, 'UNB8', 1002, 8, 'n'),
(63, 'AVER1', 1010, 9, 'n'),
(64, 'REFN', 1019, 30, 'a'),
]
# SART 75/76: zulaessige Abmeldegruende (DM-ORG D.22).
_WOCHENGELD_AGRD = ('02', '03', '04', '05', '06', '10', '22', '24')
def _monats_laenge(monat):
"""Letzter Kalendertag des Monats."""
naechster = (monat.replace(day=28) + timedelta(days=4)).replace(
day=1)
return (naechster - timedelta(days=1)).day
class L10nAtPayrollAeb(models.Model):
_name = 'l10n.at.payroll.aeb'
_description = 'Arbeits- und Entgeltbestätigung (Krankengeld/Wochengeld)'
_inherit = ['mail.thread']
_order = 'id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company, index=True)
art = fields.Selection(
[('krankengeld', 'Krankengeld (BEST AK, SART 70/71)'),
('wochengeld', 'Wochengeld (BEST AW, SART 75/76)')],
required=True, tracking=True)
employee_id = fields.Many2one('hr.employee', required=True)
kzku = fields.Selection(
[('1', 'Krankheit/Unglücksfall'),
('2', 'Arbeitsunfall/Berufskrankheit')],
string='Kennzeichen (KZKU)', default='1')
verhinderung_von = fields.Date(
string='Verhinderung ab (Episode)', required=True,
help='Beginn der zusammenhängenden Arbeitsunfähigkeit '
'(Krankengeld) bzw. der Mutterschutz-Frist (Wochengeld) — '
'Bezugszeitraum der Bestätigung.')
verhinderung_bis = fields.Date(
string='Verhinderung bis (bekannt)',
help='Letzter bekannter Verhinderungstag; leer = laufend.')
storno_id = fields.Many2one(
'l10n.at.payroll.aeb', string='Stornierte Bestätigung',
help='Storno (SART 71/76) — nur bei Fehler oder Änderung der '
'Bemessungsgrundlage (E.10.2.1); sonst Korrektur per '
'Neumeldung (SART 70/75).')
refn = fields.Char(string='Referenznummer (REFN)', readonly=True,
copy=False)
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)
# ------------------------------------------------------------------
# Datenaufbereitung
# ------------------------------------------------------------------
def _aeb_version(self):
return self.employee_id.version_id
def _aeb_referenz_slips(self, bis, monate):
"""Die bis zu ``monate`` jüngsten vollständigen validierten
Monatsläufe vor ``bis`` (älteste zuerst)."""
self.ensure_one()
slips = self.env['hr.payslip'].search([
('employee_id', '=', self.employee_id.id),
('state', 'in', ('validated', 'paid')),
('date_to', '<', bis),
], order='date_from desc', limit=monate).filtered(
lambda s: s.date_from.day == 1
and s.date_to.day == _monats_laenge(s.date_from))
return slips.sorted('date_from')
def _aeb_monats_brutto(self, slip):
"""Monats-Bezug (Brutto inkl. SZ-Anteil) eines Laufs."""
return slip._l10n_at_brutto('laufend') \
+ slip._l10n_at_brutto('sonstig')
def _aeb_szkz(self):
"""SZKZ: Anspruch auf Sonderzahlung — datengetrieben aus den
sonstigen (§-67-)Bezügen irgendeines validierten Laufs des
AN (B13/14-Struktur des Referenz-KVs; die reinen
Referenzmonate können sz-frei sein)."""
self.ensure_one()
slips = self.env['hr.payslip'].search([
('employee_id', '=', self.employee_id.id),
('state', 'in', ('validated', 'paid')),
], limit=24)
return 'J' if any(s._l10n_at_brutto('sonstig') for s in slips) \
else 'N'
def _aeb_wochenplan(self):
"""ARFT: 'A' je Arbeitstag, 'F' sonst (MontagSonntag)."""
self.ensure_one()
kalender = self._aeb_version().resource_calendar_id
tage = {int(att.dayofweek)
for att in kalender.attendance_ids} \
if kalender else {0, 1, 2, 3, 4}
return ''.join('A' if d in tage else 'F' for d in range(7))
def _aeb_ident_setzen(self, satz, wfkz_feld):
"""Identifikations- und Beschäftigungsfelder beider
Satzarten. ``wfkz_feld``: 'WFKZ' (Krankengeld) bzw. 'WKFZ'
(Wochengeld) — die Feldnamen weichen ab (G.10/G.11)."""
self.ensure_one()
version = self._aeb_version()
company = self.company_id
employee = self.employee_id
familienname, vorname = l10n_at_namen(employee)
satz.setze('BKNR', company.l10n_at_beitragskontonummer)
satz.setze('DGNA', (company.name or '')[:70])
satz.setze('DTEL', (company.partner_id.phone or '')[:50])
satz.setze('VSNR', version.ssnid)
satz.setze_datum('GEBD', employee.birthday)
satz.setze('FANA', familienname[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze_datum('BEAB', version.contract_date_start)
satz.setze('KABE', '4' if version.l10n_at_lehrling else (
'1' if getattr(version, 'l10n_at_personalgruppe', '')
== 'arbeiter' else '2'))
satz.setze('TAET', (employee.job_id.name or '')[:30])
plan = self._aeb_wochenplan()
satz.setze('BTAG', str(plan.count('A')))
satz.setze('TATU', str(min(plan.count('A'), 6)))
# Wohnort (D.12): A = Österreich
satz.setze(wfkz_feld, 'A')
satz.setze('PLZL', (version.private_zip or '')[:9])
satz.setze('WORT', (version.private_city or '')[:40])
satz.setze('STRA', (version.private_street or '')[:50])
if version.contract_date_end:
satz.setze('BLOE', 'G')
satz.setze_datum('EBSV', version.contract_date_end)
agrd = getattr(version.departure_reason_id, 'l10n_at_agrd',
False)
if agrd:
satz.setze('AGRD', agrd)
satz.setze_datum('LTAG', version.contract_date_end)
else:
satz.setze('BLOE', 'N')
bank = employee.primary_bank_account_id
if bank:
satz.setze('IBAN', (bank.acc_number or '')[:34])
if bank.bank_id:
satz.setze('BIC', (bank.bank_id.bic or '')[:11])
# ------------------------------------------------------------------
# Fachdaten
# ------------------------------------------------------------------
def _aeb_fuelle_krankengeld(self, satz, fehler):
"""SART 70: EFZ-Anspruch (AEFZ/JAGU/TAGU), Teilentgelt-Phase,
Vorerkrankungen, Bezugsblöcke, VENT (Phasen-Karte AP4)."""
self.ensure_one()
version = self._aeb_version()
episode_von = self.verhinderung_von
aj_start = version._l10n_at_efz_arbeitsjahr_start(episode_von)
if not aj_start:
fehler.append('Kein Vertragsbeginn — EFZ-Daten leer.')
return
endtag = self.verhinderung_bis or fields.Date.context_today(self)
kontext = version._l10n_at_efz_phasen(
aj_start, max(endtag, episode_von))
phasen = kontext['phasen']
satz.setze('KZKU', self.kzku or '1')
satz.setze('ARFT', self._aeb_wochenplan())
wochen = version._l10n_at_efz_stufe_am(episode_von)
if wochen:
satz.setze('AEFZ', '%02d' % wochen)
satz.setze('JAGU', '1') # Arbeitsjahr (lb-krs-08)
satz.setze('TAGU', '2') # Kalendertage
satz.setze('SBKZ', 'N') # kein Sachbezug modelliert
def bloecke(phase=None, filter_ab=None):
"""Zusammenhängende Kalendertag-Blöcke einer Phase."""
ergebnis = []
vorher = None
for tag in sorted(phasen):
if filter_ab is not None and tag >= filter_ab:
break
if phase is not None and phasen[tag] != phase:
vorher = None
continue
if vorher is not None and (tag - vorher).days == 1:
ergebnis[-1][1] = tag
else:
ergebnis.append([tag, tag])
vorher = tag
return ergebnis
# VENT: Ende der zusammenhängenden Voll-Phase ab Episode.
voll = bloecke(phase='voll')
voll_der_episode = [b for b in voll if b[0] >= episode_von]
if voll_der_episode:
satz.setze_datum('VENT', voll_der_episode[0][1])
# Teilentgelt: bis zu zwei Halb-Phasen (TPR/TVO/TBI).
for index, (von, bis) in enumerate(bloecke(phase='halb')[:2]):
satz.setze('TPR%d' % (index + 1), 50)
satz.setze_datum('TVO%d' % (index + 1), von)
satz.setze_datum('TBI%d' % (index + 1), bis)
# Vorerkrankungen (ANV/ANB): Blöcke vor der Episode
# (vorangehende Arbeitsunfähigkeiten, E.10.2.7).
for index, (von, bis) in enumerate(bloecke(filter_ab=episode_von)[:10]):
satz.setze_datum('ANV%d' % (index + 1), von)
satz.setze_datum('ANB%d' % (index + 1), bis)
# Bezugsblöcke: bis zu 4 volle Monatsläufe vor Episode.
slips = self._aeb_referenz_slips(episode_von, 4)[:4]
summe = 0.0
for index, slip in enumerate(slips):
betrag = self._aeb_monats_brutto(slip)
summe += betrag
satz.setze_datum('BVO%d' % (index + 1), slip.date_from)
satz.setze_datum('BBI%d' % (index + 1), slip.date_to)
satz.setze('BBE%d' % (index + 1), betrag, cent=True)
if summe:
satz.setze('BSUM', summe, cent=True)
satz.setze('SZKZ', self._aeb_szkz())
def _aeb_fuelle_wochengeld(self, satz, fehler):
"""SART 75: Arbeitsverdienst netto der letzten drei
Kalendermonate vor der Frist (§ 162 Abs 3 ASVG — AP7-Logik),
Fortbezug, AGRD-Kontrolle (D.22)."""
self.ensure_one()
von = self.verhinderung_von
referenz = self._aeb_referenz_slips(von, 3)
if referenz:
netto = sum(sum(s.line_ids.filtered(
lambda l: l.code == 'NET').mapped('total'))
for s in referenz)
satz.setze('AVER', netto, cent=True)
satz.setze_datum('AVON', referenz[0].date_from)
satz.setze_datum('ABIS', referenz[-1].date_to)
else:
fehler.append(
'Keine vollständigen Referenzläufe für den '
'Arbeitsverdienst (§ 162 Abs 3 ASVG).')
satz.setze('SZKZ', self._aeb_szkz())
satz.setze('ANGV', 'K') # kein Fortbezug des Entgelts (AP7)
agrd = satz.wert('AGRD')
if agrd and agrd not in _WOCHENGELD_AGRD:
fehler.append(
'Abmeldegrund %s ist für SART 75/76 unzulässig '
'(D.22: %s) — Feld geleert.' % (
agrd, ', '.join(_WOCHENGELD_AGRD)))
# setze(None) übernähme die Grundstellung nicht — Feld
# aktiv mit Blank leeren.
satz.setze('AGRD', '')
# ------------------------------------------------------------------
# Dateierzeugung
# ------------------------------------------------------------------
def action_erzeugen(self):
import datetime as datetime_modul
for aeb in self:
fehler = []
company = aeb.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
raise UserError('Beitragskontonummer (BKNR) fehlt.')
version = aeb._aeb_version()
if not version.ssnid:
raise UserError(
'Mitarbeiter %s ohne Versicherungsnummer.'
% aeb.employee_id.name)
storno = bool(aeb.storno_id)
if aeb.art == 'krankengeld':
satzart = '71' if storno else '70'
struktur = L10N_AT_ELDA_IDTEIL + AEB_KRANKENGELD_SATZ
laenge, bestand, vers = 1392, 'AK', '12'
praefix, wfkz_feld = 'AEBK', 'WFKZ'
else:
satzart = '76' if storno else '75'
struktur = L10N_AT_ELDA_IDTEIL + AEB_WOCHENGELD_SATZ
laenge, bestand, vers = 1048, 'AW', '10'
praefix, wfkz_feld = 'AEBW', 'WKFZ'
satz = L10nAtEldaSatz(
struktur, laenge,
bezeichnung='AEB %s' % aeb.employee_id.name)
satz.setze('SART', satzart)
stichtag = aeb.verhinderung_bis \
or datetime_modul.date.today()
aeb._aeb_ident_setzen(satz, wfkz_feld)
if storno:
# Storno (E.10.2.1): Kerndaten der stornierten
# Bestätigung übernehmen, REFN unten neu setzen.
original = aeb.storno_id
satz.setze('VSNR',
original._aeb_version().ssnid)
satz.setze_datum('GEBD', original.employee_id.birthday)
familienname, vorname = l10n_at_namen(
original.employee_id)
satz.setze('FANA', familienname[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze_datum('BEAB',
original._aeb_version()
.contract_date_start)
elif aeb.art == 'krankengeld':
aeb._aeb_fuelle_krankengeld(satz, fehler)
else:
aeb._aeb_fuelle_wochengeld(satz, fehler)
if not aeb.refn:
aeb.refn = '%s-%d-%s-%d%s' % (
praefix, company.id,
aeb.verhinderung_von.strftime('%Y%m%d'),
aeb.employee_id.id, '-ST' if storno else '')
satz.setze('REFN', aeb.refn[:30])
vstr = (bknr or '')[:2]
bestands_objekt = L10nAtEldaBestand(
bestand, vstr, vers,
hersteller={'name': 'odoo-at-payroll', 'kfz': 'A',
'plz': company.partner_id.zip,
'ort': company.partner_id.city,
'strasse': company.partner_id.street},
datensaetze=[satz])
datei = L10nAtEldaDatei([bestands_objekt])
roh = datei.render()
# Selbstkontrolle: Datensatz-Satzlaenge (nach Header,
# Vorlauf; vor Schluss).
datensaetze = roh.decode('ISO-8859-15') \
.strip('\r\n').split('\r\n')
if len(datensaetze[2]) != laenge:
fehler.append(
'Satzlängenkontrolle: %d != %d'
% (len(datensaetze[2]), laenge))
aeb.write({
'datei': base64.b64encode(roh).decode(),
'datei_name': '%s_%s.txt' % (
praefix.lower(),
aeb.verhinderung_von.strftime('%Y%m%d')),
'protokoll': 'WARNUNGEN:\n%s' % (
'\n'.join(fehler) or ''),
'state': 'done',
})
return True
+143 -107
View File
@@ -41,6 +41,135 @@ _KRANK_WET = 'ATP_KRANK'
_AU_WET = 'ATP_AU' _AU_WET = 'ATP_AU'
class HrVersion(models.Model):
_inherit = 'hr.version'
def _l10n_at_efz_arbeitsjahr_start(self, stichtag):
"""Beginn des Arbeitsjahres, das den Stichtag enthält
(Eintrittsdatum-Rhythmus, lb-krs-08). None ohne
Vertragsbeginn."""
self.ensure_one()
beginn = self.contract_date_start
if not beginn:
return None
jahre = beginn.year
while True:
aj_start = date(jahre, beginn.month, beginn.day) \
if not (beginn.month == 2 and beginn.day == 29) \
else date(jahre, 3, 1)
aj_naechstes = date(jahre + 1, aj_start.month, aj_start.day)
if aj_start <= stichtag < aj_naechstes:
return aj_start
jahre += 1
if jahre > beginn.year + 80:
return None
def _l10n_at_efz_phasen(self, aj_start, endtag, stichtag=None):
"""Komplette EFZ-Phasen-Karte des Arbeitsjahres über alle
Krank-/AU-Kalendertage bis ``endtag``: {datum: 'voll'/'halb'/
'oegk'} plus Kontext-Parameter — gemeinsame Basis der
Payslip-Auswertung (AP4) und der Arbeits- und
Entgeltbestätigung (AP8).
Stufen je Tag (Stufensprung verlängert, lb-krs-08),
Krank-/AU-Topf getrennt (AU ohne Halbsatz, § 8 Abs 2a AngG /
§ 2 Abs 5 EFZG)."""
self.ensure_one()
stichtag = stichtag or fields.Date.context_today(self)
params = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_efz_stufen', stichtag, raise_if_not_found=False) or {}
stufen = sorted(
(int(jahre), int(wochen))
for jahre, wochen in params.get('stufen', []))
halb_wochen = int(params.get('halb_wochen', 4))
au_wochen = int(params.get('au_wochen', 8))
au2_wochen = int(params.get('au2_wochen', 10))
au_ab_jahre = int(params.get('au_ab_jahre', 15))
typen = self.env['hr.leave.type'].search([
('work_entry_type_id.code', 'in', (_KRANK_WET, _AU_WET))])
leaves = self.env['hr.leave'].search([
('employee_id', '=', self.employee_id.id),
('holiday_status_id', 'in', typen.ids),
('state', 'in', ('validate', 'validate1')),
])
krank_tage, au_tage = {}, {}
for leave in leaves:
if not (leave.request_date_from and leave.request_date_to):
continue
tag = leave.request_date_from
while tag <= leave.request_date_to:
if aj_start <= tag <= endtag:
topf = au_tage if leave.holiday_status_id \
.work_entry_type_id.code == _AU_WET else krank_tage
topf[tag] = True
tag += timedelta(days=1)
beginn = self.contract_date_start
def stufe_fuer(dienstjahre, tag_ist_au):
wochen = stufen[0][1] if stufen else 6
for jahre, stufe in stufen:
if dienstjahre >= jahre:
wochen = stufe
if tag_ist_au:
wochen = au2_wochen if dienstjahre >= au_ab_jahre \
else au_wochen
return wochen
phasen = {}
verbraucht_krank_voll = verbraucht_krank_halb = 0
verbraucht_au = 0
for tag in sorted(set(krank_tage) | set(au_tage)):
dienstjahre = (tag.year - beginn.year
- ((tag.month, tag.day) < (beginn.month,
beginn.day)))
if tag in au_tage:
if verbraucht_au < stufe_fuer(dienstjahre, True) * 7:
phasen[tag] = 'voll'
else:
phasen[tag] = 'oegk'
verbraucht_au += 1
continue
wochen = stufe_fuer(dienstjahre, False)
if verbraucht_krank_voll < wochen * 7:
phasen[tag] = 'voll'
verbraucht_krank_voll += 1
elif verbraucht_krank_halb < halb_wochen * 7:
phasen[tag] = 'halb'
verbraucht_krank_halb += 1
else:
phasen[tag] = 'oegk'
return {
'phasen': phasen,
'stufen': stufen,
'halb_wochen': halb_wochen,
'au_wochen': au_wochen,
'au2_wochen': au2_wochen,
'au_ab_jahre': au_ab_jahre,
}
def _l10n_at_efz_stufe_am(self, stichtag, ist_au=False):
"""EFZ-Stufen-Wochen am Stichtag (vollendete Dienstjahre)."""
self.ensure_one()
aj_start = self._l10n_at_efz_arbeitsjahr_start(stichtag)
if not aj_start:
return None
kontext = self._l10n_at_efz_phasen(aj_start, stichtag)
beginn = self.contract_date_start
dienstjahre = (stichtag.year - beginn.year
- ((stichtag.month, stichtag.day)
< (beginn.month, beginn.day)))
stufen = kontext['stufen']
wochen = stufen[0][1] if stufen else 6
for jahre, stufe in stufen:
if dienstjahre >= jahre:
wochen = stufe
if ist_au:
wochen = kontext['au2_wochen'] \
if dienstjahre >= kontext['au_ab_jahre'] \
else kontext['au_wochen']
return wochen
class HrPayslip(models.Model): class HrPayslip(models.Model):
_inherit = 'hr.payslip' _inherit = 'hr.payslip'
@@ -65,121 +194,31 @@ class HrPayslip(models.Model):
len(efz['oegk_arbeitstage']))) len(efz['oegk_arbeitstage'])))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# EFZ-Phasenbestimmung # EFZ-Phasenbestimmung (Phasen-Karte: hr.version, AP8-Refaktur)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _at_efz_leaves(self):
"""Validierte Krankenstand-/AU-Abwesenheiten des AN als
(start, ende, ist_au)-Kalenderspannen."""
self.ensure_one()
typen = self.env['hr.leave.type'].search([
('work_entry_type_id.code', 'in',
(_KRANK_WET, _AU_WET))])
leaves = self.env['hr.leave'].search([
('employee_id', '=', self.employee_id.id),
('holiday_status_id', 'in', typen.ids),
('state', 'in', ('validate', 'validate1')),
])
return [
(leave.request_date_from, leave.request_date_to,
leave.holiday_status_id.work_entry_type_id.code == _AU_WET)
for leave in leaves
if leave.request_date_from and leave.request_date_to]
def _at_efz_arbeitsjahr_start(self):
"""Beginn des Arbeitsjahres, das die Abrechnungsperiode
überwiegend enthält (Eintrittsdatum-Rhythmus, lb-krs-08)."""
self.ensure_one()
beginn = self.version_id.contract_date_start
if not beginn:
return None
jahre = beginn.year
while True:
aj_start = date(jahre, beginn.month, beginn.day) \
if not (beginn.month == 2 and beginn.day == 29) \
else date(jahre, 3, 1)
aj_naechstes = date(jahre + 1, aj_start.month, aj_start.day)
if aj_start <= self.date_from < aj_naechstes:
return aj_start
jahre += 1
if jahre > beginn.year + 80:
return None
def _at_efz_status(self): def _at_efz_status(self):
"""EFZ-Phasen je Abrechnungsperiode: {'stufe_wochen', """EFZ-Phasen je Abrechnungsperiode: {'stufe_wochen',
'halb_wochen', 'voll_arbeitstage', 'halb_arbeitstage', 'halb_wochen', 'voll_arbeitstage', 'halb_arbeitstage',
'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn', 'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn'}
'info'} — None ohne Krankenstand/AU im Arbeitsjahr. — None ohne Krankenstand/AU im Arbeitsjahr.
Kalendertage-Maschine: Die Krankheits- und AU-Tage des Die Phasen-Karte (Kalendertage, Stufen je Tag, Krank-/AU-Topf
Arbeitsjahres werden in Kalenderreihenfolge den Phasen getrennt, § 8 Abs 2a AngG / § 2 Abs 5 EFZG) liefert
zugeteilt (voll bis Stufen-Kontingent, halb zuzüglich ``hr.version._l10n_at_efz_phasen`` — gemeinsame Basis mit der
4 Wochen, danach ÖGK); AU ausschließlich voll (kein Arbeits- und Entgeltbestätigung (AP8)."""
Halbsatz, § 8 Abs 2a AngG / § 2 Abs 5 EFZG). Ein
Stufensprung im laufenden Stand verlängert das Kontingent
(Stufe je Tag neu bewertet, lb-krs-08)."""
self.ensure_one() self.ensure_one()
version = self.version_id version = self.version_id
aj_start = self._at_efz_arbeitsjahr_start() aj_start = version._l10n_at_efz_arbeitsjahr_start(self.date_from)
if not aj_start: if not aj_start:
return None return None
endtag = max(self.date_to, aj_start) endtag = max(self.date_to, aj_start)
krank_tage, au_tage = {}, {} kontext = version._l10n_at_efz_phasen(aj_start, endtag,
episode_start = None stichtag=self.date_from)
for von, bis, ist_au in self._at_efz_leaves(): phasen = kontext['phasen']
tag = von if not phasen:
while tag <= bis:
if aj_start <= tag <= endtag:
topf = au_tage if ist_au else krank_tage
topf[tag] = True
tag += timedelta(days=1)
if not krank_tage and not au_tage:
return None return None
params = self.env['hr.rule.parameter']._get_parameter_from_code( halb_wochen = kontext['halb_wochen']
'at_efz_stufen', self.date_from, raise_if_not_found=False) or {}
stufen = sorted(
(int(jahre), int(wochen))
for jahre, wochen in params.get('stufen', []))
halb_wochen = int(params.get('halb_wochen', 4))
au_wochen = int(params.get('au_wochen', 8))
au2_wochen = int(params.get('au2_wochen', 10))
au_ab_jahre = int(params.get('au_ab_jahre', 15))
def stufe_fuer(dienstjahre, tag_ist_au):
"""Anspruchs-Wochen am Stichtag (vollendete Jahre)."""
wochen = stufen[0][1] if stufen else 6
for jahre, stufe in stufen:
if dienstjahre >= jahre:
wochen = stufe
if tag_ist_au:
wochen = au2_wochen if dienstjahre >= au_ab_jahre \
else au_wochen
return wochen
beginn = version.contract_date_start
phasen = {}
verbraucht_krank_voll = verbraucht_krank_halb = 0
verbraucht_au = 0
for tag in sorted(set(krank_tage) | set(au_tage)):
dienstjahre = (tag.year - beginn.year
- ((tag.month, tag.day) < (beginn.month,
beginn.day)))
if tag in au_tage:
if verbraucht_au < stufe_fuer(dienstjahre, True) * 7:
phasen[tag] = 'voll'
else:
phasen[tag] = 'oegk'
verbraucht_au += 1
continue
wochen = stufe_fuer(dienstjahre, False)
if verbraucht_krank_voll < wochen * 7:
phasen[tag] = 'voll'
verbraucht_krank_voll += 1
elif verbraucht_krank_halb < halb_wochen * 7:
phasen[tag] = 'halb'
verbraucht_krank_halb += 1
else:
phasen[tag] = 'oegk'
# Arbeitstage je Phase im Abrechnungszeitraum (Work Entries) # Arbeitstage je Phase im Abrechnungszeitraum (Work Entries)
eintraege = self.env['hr.work.entry'].search([ eintraege = self.env['hr.work.entry'].search([
('employee_id', '=', self.employee_id.id), ('employee_id', '=', self.employee_id.id),
@@ -208,11 +247,8 @@ class HrPayslip(models.Model):
block = block - timedelta(days=1) block = block - timedelta(days=1)
episode_beginn = block episode_beginn = block
break break
dienstjahre = (self.date_from.year - beginn.year
- ((self.date_from.month, self.date_from.day)
< (beginn.month, beginn.day)))
return { return {
'stufe_wochen': stufe_fuer(dienstjahre, False), 'stufe_wochen': version._l10n_at_efz_stufe_am(self.date_from),
'halb_wochen': halb_wochen, 'halb_wochen': halb_wochen,
'voll_arbeitstage': sorted(set(voll)), 'voll_arbeitstage': sorted(set(voll)),
'halb_arbeitstage': sorted(set(halb)), 'halb_arbeitstage': sorted(set(halb)),
@@ -16,4 +16,6 @@ access_l10n_at_payroll_mbgm_user,L10n AT Payroll mBGM User,model_l10n_at_payroll
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_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_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_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 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
access_l10n_at_payroll_aeb_user,L10n AT Payroll AEB User,model_l10n_at_payroll_aeb,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_aeb_manager,L10n AT Payroll AEB Manager,model_l10n_at_payroll_aeb,hr_payroll.group_hr_payroll_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
20 access_l10n_at_payroll_aeb_user L10n AT Payroll AEB User model_l10n_at_payroll_aeb hr_payroll.group_hr_payroll_user 1 1 1 0
21 access_l10n_at_payroll_aeb_manager L10n AT Payroll AEB Manager model_l10n_at_payroll_aeb hr_payroll.group_hr_payroll_manager 1 1 1 1
@@ -11,3 +11,4 @@ from . import test_wien_dag_private
from . import test_einstufung_private from . import test_einstufung_private
from . import test_efzg_private from . import test_efzg_private
from . import test_mutterschutz_private from . import test_mutterschutz_private
from . import test_aeb_private
@@ -0,0 +1,233 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP8: Arbeits- und Entgeltbestätigungen — BEST AK (Krankengeld,
SART 70/71, Version 12) und BEST AW (Wochengeld, SART 75/76,
Version 10). Satzlängen 1392/1048 gegen die G.10/G.11-Kette
(PDF-verifiziert), Fachwerte gegen die AP4/AP7-Rechenfälle."""
import base64
import datetime
from odoo import SUPERUSER_ID
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class AebPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.company.write({'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 AEB 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
self.krankenstand_typ = self.env.ref(
'l10n_at_hr_payroll_private.leave_type_at_krankenstand')
self.msch_typ = self.env.ref(
'l10n_at_hr_payroll_private.leave_type_at_mutterschutz')
def _employee(self, eintritt):
employee = self.env['hr.employee'].create({
'name': 'AEB Test', 'birthday': '1990-05-15',
'sex': 'female'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'contract_date_start': eintritt,
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'ssnid': '1234150590',
'resource_calendar_id': self.calendar.id,
})
version.generate_work_entries(
datetime.date(2026, 8, 1), datetime.date(2026, 12, 31))
return employee
def _slip(self, employee, monat):
erster = datetime.date(2026, 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': 'AEB %d/2026' % monat,
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': letzter,
})
slip.compute_sheet()
slip.action_payslip_done()
return slip
def _leave(self, employee, typ, von, bis):
leave = self.env['hr.leave'].create({
'name': '%s %s' % (typ.name, von),
'employee_id': employee.id,
'holiday_status_id': typ.id,
'request_date_from': von,
'request_date_to': bis,
})
leave.with_user(SUPERUSER_ID)._action_validate()
def _datei_zeilen(self, aeb):
roh = base64.b64decode(aeb.datei).decode('ISO-8859-15')
return roh.strip('\r\n').split('\r\n')
def test_01_aeb_krankengeld(self):
"""BEST AK / SART 70: Satzlänge 1392, Identifikation, EFZ-
Fachdaten (AEFZ 06, JAGU/TAGU, TPR1 50, VENT, Bezugsblöcke)
aus der AP4-Episode 1. 9.15. 11. 2026."""
employee = self._employee(datetime.date(2026, 7, 1))
referenz = [self._slip(employee, m) for m in (8,)]
# 76-Tage-Episode (EFZ erschöpft — 6 Wo voll bis 12. 10.,
# 4 Wo halb 13. 10.9. 11., ÖGK ab 10. 11.) wie AP4-RF-1.
self._leave(employee, self.krankenstand_typ,
datetime.date(2026, 9, 1),
datetime.date(2026, 11, 15))
self._slip(employee, 11)
aeb = self.env['l10n.at.payroll.aeb'].create({
'company_id': self.company.id,
'art': 'krankengeld',
'employee_id': employee.id,
'verhinderung_von': datetime.date(2026, 9, 1),
'verhinderung_bis': datetime.date(2026, 11, 15),
})
aeb.action_erzeugen()
self.assertEqual(aeb.state, 'done')
zeilen = self._datei_zeilen(aeb)
self.assertEqual(zeilen[0][0:4], ' 01') # Dateiheader
self.assertEqual(zeilen[1][0:2], '00') # Vorlauf
self.assertEqual(zeilen[2][0:2], '70') # SART 70
self.assertEqual(len(zeilen[2]), 1392)
self.assertEqual(zeilen[-1][0:2], '99') # Schluss
satz = zeilen[2]
# Identifikation (Slices 0-basiert = Pos 1)
self.assertEqual(satz[20:30], '1412345678') # BKNR
self.assertEqual(satz[177:187], '1234150590') # VSNR
self.assertEqual(satz[187:195], '15051990') # GEBD
self.assertEqual(satz[467:475], '01072026') # BEAB
self.assertEqual(satz[475:476], '2') # KABE Angestellte
self.assertEqual(satz[546:547], '1') # KZKU Krankheit
self.assertEqual(satz[547:554], 'AAAAAFF') # ARFT
self.assertEqual(satz[579:580], 'N') # BLOE
# EFZ-Fachdaten (1. Arbeitsjahr: 6 Wochen)
self.assertEqual(satz[808:810], '06') # AEFZ
self.assertEqual(satz[810:811], '1') # JAGU Arbeitsjahr
self.assertEqual(satz[811:812], '2') # TAGU Kalendertage
self.assertEqual(satz[812:814], '50') # TPR1
self.assertEqual(satz[814:822], '13102026') # TVO1 (13. 10.)
self.assertEqual(satz[822:830], '09112026') # TBI1 (9. 11.)
self.assertEqual(satz[800:808], '12102026') # VENT (12. 10.)
# Bezugsblock: August (voller Monat vor der Episode 1. 9.)
self.assertEqual(satz[633:641], '01082026') # BVO1
self.assertEqual(satz[649:658], '%09d'
% round(2059.41 * 100)) # BBE1
self.assertEqual(satz[733:742], '%09d'
% round(2059.41 * 100)) # BSUM
# Keine Vorerkrankungen vor der Episode (ANV1 Grundstellung 0)
self.assertEqual(satz[848:856], '0' * 8)
def test_02_aeb_wochengeld(self):
"""BEST AW / SART 75: Satzlänge 1048, AVER = Ø-Netto der drei
Referenzmonate (§ 162 Abs 3 ASVG), ANGV K."""
employee = self._employee(datetime.date(2026, 7, 1))
referenz = [self._slip(employee, m) for m in (8, 9, 10)]
netto = sum(sum(s.line_ids.filtered(
lambda l: l.code == 'NET').mapped('total'))
for s in referenz)
self._leave(employee, self.msch_typ,
datetime.date(2026, 11, 1),
datetime.date(2026, 11, 15))
self._slip(employee, 11)
aeb = self.env['l10n.at.payroll.aeb'].create({
'company_id': self.company.id,
'art': 'wochengeld',
'employee_id': employee.id,
'verhinderung_von': datetime.date(2026, 11, 1),
'verhinderung_bis': datetime.date(2026, 11, 15),
})
aeb.action_erzeugen()
zeilen = self._datei_zeilen(aeb)
self.assertEqual(zeilen[2][0:2], '75')
self.assertEqual(len(zeilen[2]), 1048)
satz = zeilen[2]
self.assertEqual(satz[365:368], 'A ') # WKFZ Österreich
self.assertEqual(satz[634:643], '%09d' % round(netto * 100))
self.assertEqual(satz[643:651], '01082026') # AVON
self.assertEqual(satz[651:659], '31102026') # ABIS
self.assertEqual(satz[747:748], 'J') # SZKZ (Nov-B13)
self.assertEqual(satz[748:749], 'K') # ANGV kein Fortbezug
self.assertEqual(satz[557:565], ' ' * 8) # RESE: a-Grundstellung Blank
def test_03_storno(self):
"""SART 71: Storno übernimmt die Kerndaten der stornierten
Bestätigung (VSNR/GEBD/BEAB) und trägt '-ST' in der REFN."""
employee = self._employee(datetime.date(2026, 7, 1))
[self._slip(employee, m) for m in (8, 9, 10)]
self._leave(employee, self.msch_typ,
datetime.date(2026, 11, 1),
datetime.date(2026, 11, 15))
original = self.env['l10n.at.payroll.aeb'].create({
'company_id': self.company.id,
'art': 'wochengeld',
'employee_id': employee.id,
'verhinderung_von': datetime.date(2026, 11, 1),
})
original.action_erzeugen()
storno = self.env['l10n.at.payroll.aeb'].create({
'company_id': self.company.id,
'art': 'wochengeld',
'employee_id': employee.id,
'verhinderung_von': datetime.date(2026, 11, 1),
'storno_id': original.id,
})
storno.action_erzeugen()
zeilen = self._datei_zeilen(storno)
self.assertEqual(zeilen[2][0:2], '76')
self.assertEqual(len(zeilen[2]), 1048)
satz = zeilen[2]
self.assertEqual(satz[177:187], '1234150590') # VSNR des Originals
self.assertTrue(storno.refn.endswith('-ST'))
self.assertEqual(satz[1018:1048].strip(), storno.refn)
def test_04_wochengeld_agrd_validierung(self):
"""Unzulässiger AGRD bei SART 75 wird geleert und protokolliert
(D.22: nur 02/03/04/05/06/10/22/24)."""
employee = self._employee(datetime.date(2026, 7, 1))
[self._slip(employee, m) for m in (8, 9, 10)]
self._leave(employee, self.msch_typ,
datetime.date(2026, 11, 1),
datetime.date(2026, 11, 15))
self._slip(employee, 11)
# Abmeldegrund 16 (Pensionierung) — für SART 75 unzulässig
grund = self.env['hr.departure.reason'].search([
('l10n_at_agrd', '=', '16')], limit=1)
employee.version_id.write({
'contract_date_end': datetime.date(2026, 11, 30),
'departure_reason_id': grund.id,
})
aeb = self.env['l10n.at.payroll.aeb'].create({
'company_id': self.company.id,
'art': 'wochengeld',
'employee_id': employee.id,
'verhinderung_von': datetime.date(2026, 11, 1),
})
aeb.action_erzeugen()
satz = self._datei_zeilen(aeb)[2]
self.assertEqual(satz[546:547], 'G') # BLOE gelöst
self.assertEqual(satz[547:549].strip(), '') # AGRD geleert
self.assertIn('unzulässig', aeb.protokoll)
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- AP8: Arbeits- und Entgeltbestätigungen (AEB) — ELDA-Bestände -->
<!-- AK (Krankengeld SART 70/71) und AW (Wochengeld SART 75/76). -->
<!-- ============================================================ -->
<record id="aeb_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.aeb.list</field>
<field name="model">l10n.at.payroll.aeb</field>
<field name="arch" type="xml">
<list string="Arbeits- und Entgeltbestätigungen">
<field name="art"/>
<field name="employee_id"/>
<field name="verhinderung_von"/>
<field name="verhinderung_bis" optional="show"/>
<field name="storno_id" optional="hide"/>
<field name="refn" optional="hide"/>
<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="aeb_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.aeb.form</field>
<field name="model">l10n.at.payroll.aeb</field>
<field name="arch" type="xml">
<form string="Arbeits- und Entgeltbestätigung">
<header>
<button name="action_erzeugen" type="object"
string="Datei erzeugen" class="btn-primary"
invisible="state == 'done'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,done"/>
</header>
<sheet>
<group>
<group>
<field name="art"/>
<field name="employee_id"/>
<field name="kzku"
invisible="art != 'krankengeld'"/>
<field name="verhinderung_von"/>
<field name="verhinderung_bis"/>
<field name="storno_id"
options="{'no_create': True}"/>
<field name="refn" readonly="1"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="datei" filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
</group>
<notebook>
<page string="Prüfprotokoll" name="protokoll">
<field name="protokoll"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_aeb" model="ir.actions.act_window">
<field name="name">Arbeits- und Entgeltbestätigungen (AEB)</field>
<field name="res_model">l10n.at.payroll.aeb</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_l10n_at_payroll_aeb"
name="Arbeits- und Entgeltbestätigungen"
parent="l10n_at_hr_payroll.menu_l10n_at_payroll_meldewesen"
action="action_aeb" sequence="20"/>
</odoo>