mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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:
@@ -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)
|
||||
Reference in New Issue
Block a user