[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
@@ -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
@@ -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)