Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/versichertenmeldung.py
T
fegger 14940ac31a [IMP] l10n_at_hr_payroll_private: GP7 mBGM/JASV + Versichertenmeldung + fallweise Anmeldung auf dem ELDA-Framework
mBGM/JASV (models/mbgm.py — DM-ORG 42.7.0 E.32, BEST MB, Version 02): Paket PS (BKNR/DGNA/BZRM/GSUM/ANZM) → mBGM G1 (REFW/VSNR/FANA/VONA/VSUM/VERG=1) → Tarifblock T1 (BSGR/VVON) → Verrechnungsbasis BS (VBTY/VBBT) → Verrechnungsposition V1 (VPTY/VPVZ/VPTA/RSVZ/RSUM). KEIN DN/DG-Zweig-Split: T01 = GESAMT-Prozentsatz des Tarifsystems je Beschäftigtengruppe (Parameter at_mbgm_tarif, normativ: laufend 39,05 % = KV 7,65 + PV 22,80 + AV 5,90 + UV 1,10 + WF 1,00 + IESG 0,10; SZ 37,55 % = DN KV 3,87 + PV 10,25 + AV 2,95 + DG 20,48). Abschläge als EIGENE Verrechnungspositionen je Basis: A03/A02/A01 = Minderung AV auf 0/1/2 % (§ 2a AMPFG, nur DN-AV, je Verrechnungsbasis AB UND SZ), A09 = UV-Entfall 60, A10 = AV+IE-Entfall Pensionsanspruch (Konkurrenz a4: sperrt Staffelabschlag), A15 = PV-Halbierung Bonusphase — konsistent mit der GP4-§-2a-DN-seitig-Konvention. BV (BMSVG): eigene Verrechnungsbasis BV (ungedeckelt, GP5) + Position V01 1,53 %. RSUM = Basis x Satz (mBGM-Tarifierung), Konsistenz-Check gegen GP4-Payslip-Beitragssummen ±0,10 € als Prüfwarnung. Storno: R1-Sätze mit REFU/VSUM wie Original. Fehlanzeige: Paket ohne mBGM-Sätze (ANZM 0). Geringfügige/fallweise ohne Parametrisierung: listenweiser Abbruch (keine Tarif-Erfindung; BSGR B044/B045/B010/B030 aus dem SV-Tarifsystem pflegen).

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

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

Tests: test_meldewesen_private (2 — mBGM-Satzhierarchie mit A03 je Basis aus November-Fixture + GSUM-Konsistenz; VR M3/M4 mit Registry-Idempotenz und AGRD). Rechenfälle docs/rechenfaelle/meldewesen.md. 19.0.7.0.0.
2026-09-12 17:31:12 +02:00

406 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Versichertenmeldung reduziert (GP7/AP5) — DM-ORG 42.7.0 Kapitel
E.29 (BEST ``VR``, Version 03, Satzlänge 772; PDF-verifizierte
Feldtabelle 2026-09-12): Anmeldung M3 (vor Arbeitsantritt), Abmeldung
M4 (7 Kalendertage ab PV-Ende), Änderungsmeldung M6 nur für
Ausnahmefälle (Inhaltsänderungen laufen über die mBGM; lb-mel-08).
Fallweise Anmeldung (BEST ``MA``, SART 32/33, Länge 450): je
Beschäftigungstag vor Arbeitsantritt (§ 33 Abs 1a Z 1 ASVG).
Event-Modell deklarativ (Dimona-auto-Muster, ohne write-diff):
offene Anmeldungen = älteste Version eines Beschäftigungsspannen-
Beginns ohne M3-Eintrag in der Meldungs-Registry; offene Abmeldungen
= beendete Versionen ohne M4-Eintrag. Die Registry
(``l10n.at.payroll.vr.event``) protokolliert gesendete Meldungen
(REFW/ADAT), damit erneute Läufe idempotent bleiben.
Felder: BBER 01 Arbeiter/02 Angestellte(r)/0304 Lehrling (D.39),
GERF geringfügig (J/N), FRDV freies Dienstverhältnis, ADAT An-/
Abmeldedatum, EBSV arbeitsrechtliches Ende vs ADAT (UEL/KE-Verlänger
ung der PV sind nicht modelliert → ADAT = contract_date_end),
AGRD eSV-Abmeldegrund (Mapping am hr.departure.reason, Standard 00),
BVAB/BVJN aus dem GP5-BMSVG-Flag, VWAZ vereinbarte Wochenarbeitszeit
(Stunden ×100, neu für Meldedaten ab 01.01.2026).
Dateibasiert (Prämisse P7); Bestätigungen/Übertragung beim Mandanten."""
from datetime import date
import base64
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.addons.l10n_at_hr_payroll.models.elda import (
L10N_AT_ELDA_IDTEIL,
L10nAtEldaBestand,
L10nAtEldaDatei,
L10nAtEldaSatz,
)
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \
l10n_at_namen
# Versichertenmeldung (E.29, Version 03) — nach IDTEIL, Länge 772.
VR_SATZ = [
(2, 'REFW', 21, 40, 'a'),
(3, 'REFU', 61, 40, 'a'),
(4, 'BKNR', 101, 10, 'a'),
(5, 'DGNA', 111, 70, 'a'),
(6, 'DTEL', 181, 50, 'a'),
(7, 'MAIL', 231, 60, 'a'),
(8, 'INF1', 291, 12, 'a'),
(9, 'INF2', 303, 12, 'a'),
(10, 'VSNR', 315, 10, 'n'),
(11, 'GEBD', 325, 8, 'n'), # TTMMJJJJ
(12, 'REFV', 333, 40, 'a'),
(13, 'FANA', 373, 70, 'a'),
(14, 'VONA', 443, 70, 'a'),
(15, 'ADAT', 513, 8, 'n'), # TTMMJJJJ
(16, 'BDAT', 521, 8, 'n'),
(17, 'RDAT', 529, 8, 'n'),
(18, 'BBER', 537, 2, 'a'), # Beschäftigungsbereich (D.39)
(19, 'GERF', 539, 1, 'a'), # geringfügig J/N
(20, 'FRDV', 540, 1, 'a'), # freier Dienstvertrag J/N
(21, 'EBSV', 541, 8, 'n'), # Ende des Beschäftigungsverhältnisses
(22, 'AGRD', 549, 2, 'a'), # Abmeldegrund (D.22)
(23, 'SAGR', 551, 20, 'a'),
(24, 'KEAB', 571, 8, 'n'),
(25, 'KEBI', 579, 8, 'n'),
(26, 'UEAB', 587, 8, 'n'),
(27, 'UEBI', 595, 8, 'n'),
(28, 'BVAB', 603, 8, 'n'), # BV-Beitrag ab
(29, 'BVEN', 611, 8, 'n'),
(30, 'BVJN', 619, 1, 'a'), # Betriebliche Vorsorge J/N
(31, 'UMDA', 620, 8, 'n'),
(32, 'RUMD', 628, 8, 'n'),
(33, 'SOUM', 636, 1, 'a'),
(34, 'ZTUM', 637, 2, 'a'),
(35, 'ZKUM', 639, 10, 'a'),
(36, 'RWUM', 649, 40, 'a'),
(37, 'RUUM', 689, 40, 'a'),
(38, 'BKUM', 729, 40, 'a'),
(39, 'VWAZ', 769, 4, 'n'), # Stunden ×100
]
# Fallweise Anmeldung (E.18, Version 07) — nach IDTEIL, Länge 450.
FALLWEISE_SATZ = [
(2, 'BKNR', 21, 10, 'a'),
(3, 'DGNA', 31, 70, 'a'),
(4, 'VSNR', 101, 10, 'n'),
(5, 'GEBD', 111, 8, 'n'),
(6, 'FANA', 119, 70, 'a'),
(7, 'VONA', 189, 70, 'a'),
(8, 'GESL', 259, 1, 'n'), # 1 männlich/2 weiblich …
(9, 'ADAT', 260, 8, 'n'), # Tag der fallweisen Beschäftigung
(10, 'BKFZ', 268, 3, 'a'),
(11, 'BPLZ', 271, 9, 'a'),
(12, 'BORT', 280, 40, 'a'),
(14, 'WOBD', 321, 12, 'a'),
(15, 'ZOBD', 333, 15, 'a'),
(17, 'REFN', 410, 30, 'a'),
(18, 'VTAZ', 440, 4, 'n'), # Stunden ×100
]
class L10nAtPayrollVrEvent(models.Model):
"""Meldungs-Registry: je gesendeter Meldung ein Eintrag (Art,
ADAT, REFW) — Grundlage für die deklarative Event-Erkennung der
offenen An-/Abmeldungen."""
_name = 'l10n.at.payroll.vr.event'
_description = 'Versichertenmeldung — gesendete Meldungen'
_order = 'adatum, id'
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
employee_id = fields.Many2one('hr.employee', required=True,
index=True, ondelete='cascade')
version_id = fields.Many2one('hr.version', required=True,
ondelete='cascade')
art = fields.Selection(
[('M3', 'Anmeldung'), ('M4', 'Abmeldung'), ('M6', 'Änderung'),
('MA', 'Fallweise Anmeldung')],
required=True)
adatum = fields.Date(required=True)
refw = fields.Char(required=True)
class L10nAtPayrollVr(models.Model):
_name = 'l10n.at.payroll.vr'
_description = 'Versichertenmeldung (An-/Abmeldung) — ELDA-Lauf'
_inherit = ['mail.thread']
_order = 'id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
stichtag = fields.Date(
default=fields.Date.today,
help='Anmeldungen bis zu diesem Tag (M3 ist eigentlich VOR '
'Arbeitsantritt zu übermitteln — der Lauf meldet '
'beginnende Beschäftigungsverhältnisse).')
nur_fallweise = fields.Boolean(
string='Nur fallweise Anmeldungen (BEST MA)',
help='Erzeugt ausschließlich Anmeldungen fallweise '
'Beschäftigter (je Beschäftigungstag, SART 32).')
datei = fields.Binary(attachment=True, copy=False)
datei_name = fields.Char(copy=False)
protokoll = fields.Text(string='Prüfprotokoll')
state = fields.Selection(
[('draft', 'Entwurf'), ('done', 'Erzeugt')], default='draft',
required=True, tracking=True)
event_ids = fields.Many2many(
'l10n.at.payroll.vr.event', string='Gesendete Meldungen',
copy=False)
def _l10n_at_offene_anmeldungen(self):
"""Älteste aktive Version je AN ohne M3-Eintrag: Beginn eines
Beschäftigungsverhältnisses (früheste contract_date_start)."""
self.ensure_one()
offene = []
versionen = self.env['hr.version'].search([
('company_id', '=', self.company_id.id),
('contract_date_start', '!=', False),
('active', '=', True),
]).with_company(self.company_id)
nach_an = {}
for version in versionen:
nach_an.setdefault(version.employee_id, version)
if version.contract_date_start \
< nach_an[version.employee_id].contract_date_start:
nach_an[version.employee_id] = version
registry = self.env['l10n.at.payroll.vr.event'].search([
('company_id', '=', self.company_id.id),
('art', 'in', ('M3', 'MA')),
])
gesehen = {(e.employee_id.id, e.adatum) for e in registry}
for employee, version in nach_an.items():
if (employee.id, version.contract_date_start) in gesehen:
continue
if version.contract_date_start > self.stichtag:
continue
offene.append(version)
return sorted(offene, key=lambda v: v.contract_date_start)
def _l10n_at_offene_abmeldungen(self):
self.ensure_one()
abmeldungen = []
versionen = self.env['hr.version'].search([
('company_id', '=', self.company_id.id),
('contract_date_end', '!=', False),
]).with_company(self.company_id)
registry = self.env['l10n.at.payroll.vr.event'].search([
('company_id', '=', self.company_id.id),
('art', '=', 'M4'),
])
gesehen = {(e.employee_id.id, e.adatum) for e in registry}
for version in versionen:
if (version.employee_id.id, version.contract_date_end) \
in gesehen:
continue
abmeldungen.append(version)
return sorted(abmeldungen, key=lambda v: v.contract_date_end)
def _l10n_at_bber(self, version):
if version.l10n_at_lehrling:
return '03' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else '04'
return '01' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else '02'
def _l10n_at_geringfuegig(self, version):
"""Vorausschauende Geringfügigkeits-Einschätzung zur Anmeldung:
Kataloggehalt/Überzahlung gegen die GFG (§ 5 Abs 2 ASVG); die
endgültige Bewertung läuft monatlich über den GP4-Kern."""
gfg = float(self.env['hr.rule.parameter']
._get_parameter_from_code(
'at_sv_gfg_monatlich', version.contract_date_start,
raise_if_not_found=False) or 0.0)
gehalt = version.wage \
or (version.kv_gruppe_id._get_gehalt(
version.erfahrungsstufe, version.contract_date_start)
if getattr(version, 'kv_gruppe_id', False) else 0.0)
ueberzahlung = getattr(version, 'ueberzahlung', 0.0) or 0.0
if ueberzahlung:
gehalt = ueberzahlung
return bool(gfg and gehalt and gehalt <= gfg)
def action_erzeugen(self):
for lauf in self:
fehler = []
company = lauf.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
raise UserError('Beitragskontonummer (BKNR) fehlt.')
datensaetze = []
ereignisse = []
vstr = bknr[:2]
if lauf.nur_fallweise:
versionen = self.env['hr.version'].search([
('company_id', '=', company.id),
('l10n_at_fallweise', '=', True),
('active', '=', True),
])
registry = self.env['l10n.at.payroll.vr.event'].search([
('company_id', '=', company.id),
('art', '=', 'MA'),
])
gesehen = {(e.employee_id.id, e.adatum)
for e in registry}
for version in versionen:
if not version.ssnid:
fehler.append('%s ohne Versicherungsnummer.'
% version.employee_id.name)
continue
# MVP: je AN ein Anmeldetag (der Vertragsbeginn);
# tagesgenaue Einsatztage = documented Rest
# (Work-Entry-Auswertung).
adat = version.contract_date_start
if not adat or (version.employee_id.id, adat) \
in gesehen:
continue
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + FALLWEISE_SATZ, 450,
bezeichnung='Fallweise %s'
% version.employee_id.name)
satz.setze('SART', '32')
satz.setze('VSTR', vstr)
satz.setze('BKNR', bknr)
satz.setze('DGNA', company.name[:70])
satz.setze('VSNR', version.ssnid)
satz.setze_datum('GEBD', version.employee_id.birthday)
satz.setze('FANA', (version.private_last_name
or '')[:70])
satz.setze('VONA', (version.private_first_name
or '')[:70])
satz.setze('GESL', '2'
if version.sex == 'female' else '1')
satz.setze_datum('ADAT', adat)
kalender = version.resource_calendar_id
if kalender:
satz.setze('VTAZ', kalender.hours_per_day,
skala=2)
refn = 'MA-%d-%s-%d' % (
company.id, adat.strftime('%Y%m%d'),
version.employee_id.id)
satz.setze('REFN', refn)
datensaetze.append(satz)
ereignisse.append((version, 'MA', adat, refn))
else:
for version in lauf._l10n_at_offene_anmeldungen():
if not version.ssnid:
fehler.append(
'Anmeldung %s ohne Versicherungsnummer.'
% version.employee_id.name)
continue
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
bezeichnung='M3 %s' % version.employee_id.name)
refw = 'M3-%d-%s-%d' % (
company.id,
version.contract_date_start.strftime('%Y%m%d'),
version.employee_id.id)
satz.setze('SART', 'M3')
satz.setze('VSTR', vstr)
satz.setze('REFW', refw)
satz.setze('BKNR', bknr)
satz.setze('DGNA', company.name[:70])
satz.setze('VSNR', version.ssnid)
satz.setze_datum(
'GEBD', version.employee_id.birthday)
familienname, vorname = l10n_at_namen(
version.employee_id)
satz.setze('FANA', familienname[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze_datum('ADAT', version.contract_date_start)
satz.setze('BBER', lauf._l10n_at_bber(version))
satz.setze('GERF', 'J' if lauf
._l10n_at_geringfuegig(version) else 'N')
satz.setze('FRDV', 'N')
if getattr(version, 'l10n_at_abfertigung_neu',
False):
satz.setze('BVAB',
version.contract_date_start
.strftime('%d%m%Y'))
satz.setze('BVJN', 'J')
kalender = version.resource_calendar_id
if kalender:
satz.setze('VWAZ', kalender.hours_per_week,
skala=2)
datensaetze.append(satz)
ereignisse.append((version, 'M3',
version.contract_date_start,
refw))
for version in lauf._l10n_at_offene_abmeldungen():
if not version.ssnid:
fehler.append(
'Abmeldung %s ohne Versicherungsnummer.'
% version.employee_id.name)
continue
satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + VR_SATZ, 772,
bezeichnung='M4 %s' % version.employee_id.name)
refw = 'M4-%d-%s-%d' % (
company.id,
version.contract_date_end.strftime('%Y%m%d'),
version.employee_id.id)
agrd = getattr(
version.departure_reason_id, 'l10n_at_agrd',
False) or '00'
satz.setze('SART', 'M4')
satz.setze('VSTR', vstr)
satz.setze('REFW', refw)
satz.setze('BKNR', bknr)
satz.setze('DGNA', company.name[:70])
satz.setze('VSNR', version.ssnid)
satz.setze_datum(
'GEBD', version.employee_id.birthday)
familienname, vorname = l10n_at_namen(
version.employee_id)
satz.setze('FANA', familienname[:70])
satz.setze('VONA', (vorname or '')[:70])
satz.setze_datum('ADAT', version.contract_date_end)
satz.setze_datum('EBSV', version.contract_date_end)
satz.setze('AGRD', agrd)
datensaetze.append(satz)
ereignisse.append((version, 'M4',
version.contract_date_end,
refw))
if fehler:
lauf.protokoll = 'FEHLER:\n' + '\n'.join(fehler)
raise UserError('Versichertenmeldung mit Fehlern '
'abgebrochen:\n' + '\n'.join(fehler))
if not datensaetze:
raise UserError('Keine offenen Meldungen zum '
'Stichtag %s.' % lauf.stichtag)
bestand = L10nAtEldaBestand(
'MA' if lauf.nur_fallweise else 'VR', vstr, '07'
if lauf.nur_fallweise else '03',
hersteller={'name': 'odoo-at-payroll', 'kfz': 'A',
'plz': company.partner_id.zip,
'ort': company.partner_id.city,
'strasse': company.partner_id.street},
datensaetze=datensaetze)
datei = L10nAtEldaDatei([bestand])
ereignis_werte = []
for version, art, adatum, refw in ereignisse:
ereignis_werte.append({
'company_id': company.id,
'employee_id': version.employee_id.id,
'version_id': version.id,
'art': art,
'adatum': adatum,
'refw': refw,
})
ereignisse = self.env['l10n.at.payroll.vr.event'].create(
ereignis_werte) if ereignis_werte else \
self.env['l10n.at.payroll.vr.event']
lauf.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'versichertenmeldung_%s.txt' % (
fields.Date.today().strftime('%Y%m%d'),),
'event_ids': [(6, 0, ereignisse.ids)],
'state': 'done',
'protokoll': 'Meldungen: %d' % len(datensaetze),
})
return True