From c3b9d6eb8e080518b0f3ad94101d67d3d86faa23 Mon Sep 17 00:00:00 2001 From: fegger Date: Sat, 12 Sep 2026 23:52:11 +0200 Subject: [PATCH] =?UTF-8?q?[IMP]=20l10n=5Fat=5Fhr=5Fpayroll=5Fprivate:=20A?= =?UTF-8?q?rbeits-=20und=20Entgeltbest=C3=A4tigungen=20BEST=20AK/AW=20?= =?UTF-8?q?=E2=80=94=20SART=2070/71=20Kranken-/75/76=20Wochengeld=20(AP8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/MEMORY.md | 60 ++ .../__manifest__.py | 4 +- .../models/__init__.py | 3 +- .../l10n_at_hr_payroll_private/models/aeb.py | 561 ++++++++++++++++++ .../l10n_at_hr_payroll_private/models/efzg.py | 250 ++++---- .../security/ir.model.access.csv | 4 +- .../tests/__init__.py | 1 + .../tests/test_aeb_private.py | 233 ++++++++ .../views/aeb_views.xml | 79 +++ 9 files changed, 1085 insertions(+), 110 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/models/aeb.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_aeb_private.py create mode 100644 addons/l10n_at_hr_payroll_private/views/aeb_views.xml diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index ddbdc81..b281fc0 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,6 +2,60 @@ ## Current focus +**AP 8 Arbeits-/Entgeltbestätigungen umgesetzt — 152/152 Tests +grün — damit ist der AP0–AP10-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 AP0–AP10-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; Σ AP11–AP19 ≈ 90–142 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** (2026-09-12): @@ -1446,6 +1500,12 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“). ## 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, LStR-2002-XML, DM-ORG 42.7.0 + Prüfkataloge + XSDs, SV-Werte 2026; Verzeichnis `_README.md` mit GesNr und Kurztitel-Fallen; lokal, diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index 9697974..be10f5b 100644 --- a/addons/l10n_at_hr_payroll_private/__manifest__.py +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', - 'version': '19.0.9.3.0', + 'version': '19.0.9.4.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -110,6 +110,7 @@ gem360-Brücke). 'data/salary_rules_msch_private.xml', 'data/salary_rules_sv_private.xml', 'data/salary_rules_bvg_private.xml', + 'data/salary_rules_bvg_private.xml', 'data/hr_payroll_account_private.xml', 'data/rule_parameters_mbgm_private.xml', 'data/rule_parameters_wien_private.xml', @@ -121,6 +122,7 @@ gem360-Brücke). 'views/meldewesen_views.xml', 'views/vertragsfelder_views.xml', 'views/mitarbeiter_views.xml', + 'views/aeb_views.xml', 'report/gp8_reports.xml', 'report/buchungsbeleg_report.xml', 'report/bv_report.xml', diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index b3b1ea2..737a7b6 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -13,4 +13,5 @@ from . import fristen_private from . import buchungsbeleg from . import wien_dag from . import efzg -from . import mutterschutz \ No newline at end of file +from . import mutterschutz +from . import aeb \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/aeb.py b/addons/l10n_at_hr_payroll_private/models/aeb.py new file mode 100644 index 0000000..4aa5b3b --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/aeb.py @@ -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 (Montag–Sonntag).""" + 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 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/models/efzg.py b/addons/l10n_at_hr_payroll_private/models/efzg.py index 679ccbd..375df02 100644 --- a/addons/l10n_at_hr_payroll_private/models/efzg.py +++ b/addons/l10n_at_hr_payroll_private/models/efzg.py @@ -41,6 +41,135 @@ _KRANK_WET = 'ATP_KRANK' _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): _inherit = 'hr.payslip' @@ -65,121 +194,31 @@ class HrPayslip(models.Model): 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): """EFZ-Phasen je Abrechnungsperiode: {'stufe_wochen', 'halb_wochen', 'voll_arbeitstage', 'halb_arbeitstage', - 'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn', - 'info'} — None ohne Krankenstand/AU im Arbeitsjahr. + 'oegk_arbeitstage', 'oegk_kalendertage', 'episode_beginn'} + — None ohne Krankenstand/AU im Arbeitsjahr. - Kalendertage-Maschine: Die Krankheits- und AU-Tage des - Arbeitsjahres werden in Kalenderreihenfolge den Phasen - zugeteilt (voll bis Stufen-Kontingent, halb zuzüglich - 4 Wochen, danach ÖGK); AU ausschließlich voll (kein - 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).""" + Die Phasen-Karte (Kalendertage, Stufen je Tag, Krank-/AU-Topf + getrennt, § 8 Abs 2a AngG / § 2 Abs 5 EFZG) liefert + ``hr.version._l10n_at_efz_phasen`` — gemeinsame Basis mit der + Arbeits- und Entgeltbestätigung (AP8).""" self.ensure_one() 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: return None endtag = max(self.date_to, aj_start) - krank_tage, au_tage = {}, {} - episode_start = None - for von, bis, ist_au in self._at_efz_leaves(): - tag = von - 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: + kontext = version._l10n_at_efz_phasen(aj_start, endtag, + stichtag=self.date_from) + phasen = kontext['phasen'] + if not phasen: return None - params = self.env['hr.rule.parameter']._get_parameter_from_code( - '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' + halb_wochen = kontext['halb_wochen'] # Arbeitstage je Phase im Abrechnungszeitraum (Work Entries) eintraege = self.env['hr.work.entry'].search([ ('employee_id', '=', self.employee_id.id), @@ -208,11 +247,8 @@ class HrPayslip(models.Model): block = block - timedelta(days=1) episode_beginn = block break - dienstjahre = (self.date_from.year - beginn.year - - ((self.date_from.month, self.date_from.day) - < (beginn.month, beginn.day))) return { - 'stufe_wochen': stufe_fuer(dienstjahre, False), + 'stufe_wochen': version._l10n_at_efz_stufe_am(self.date_from), 'halb_wochen': halb_wochen, 'voll_arbeitstage': sorted(set(voll)), 'halb_arbeitstage': sorted(set(halb)), diff --git a/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv index c435f32..a6bc2b2 100644 --- a/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv +++ b/addons/l10n_at_hr_payroll_private/security/ir.model.access.csv @@ -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_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 \ No newline at end of file +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 diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py index c890b04..d67532b 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -11,3 +11,4 @@ from . import test_wien_dag_private from . import test_einstufung_private from . import test_efzg_private from . import test_mutterschutz_private +from . import test_aeb_private diff --git a/addons/l10n_at_hr_payroll_private/tests/test_aeb_private.py b/addons/l10n_at_hr_payroll_private/tests/test_aeb_private.py new file mode 100644 index 0000000..52b7e57 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_aeb_private.py @@ -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) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/views/aeb_views.xml b/addons/l10n_at_hr_payroll_private/views/aeb_views.xml new file mode 100644 index 0000000..724439a --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/views/aeb_views.xml @@ -0,0 +1,79 @@ + + + + + + + + + l10n.at.payroll.aeb.list + l10n.at.payroll.aeb + + + + + + + + + + + + + + + + l10n.at.payroll.aeb.form + l10n.at.payroll.aeb + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Arbeits- und Entgeltbestätigungen (AEB) + l10n.at.payroll.aeb + list,form + + + +
\ No newline at end of file