[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.
This commit is contained in:
2026-09-12 17:31:12 +02:00
parent c474a39466
commit 14940ac31a
11 changed files with 1434 additions and 13 deletions
@@ -6,4 +6,6 @@ from . import bv_kasse
from . import abfertigung_private
from . import bv_report
from . import account_chart_private
from . import account_posting_private
from . import account_posting_private
from . import mbgm
from . import versichertenmeldung
@@ -0,0 +1,506 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Monatliche Beitragsgrundlagenmeldung mBGM/JASV (GP7/AP5) — DM-ORG
42.7.0, Kapitel E.32 (BEST ``MB``, mBGM-Version 02; PDF-verifizierte
Extraktion 2026-09-12).
Hierarchie: Paket (SART ``PS`` Selbstabrechner, Länge 305) → mBGM
(SART ``G1``, 326) → Tarifblock (SART ``T1``, 42) → Verrechnungsbasis
(``BS``, 33) → Verrechnungsposition (``V1``, 42).
Fachliche Grundlagen:
- **KEIN DN/DG- oder Zweig-Split**: die Standardposition ``T01``
trägt den GESAMT-Prozentsatz des Tarifsystems (Parameter
``at_mbgm_tarif`` — BSGR-spezifisch; Werte aus dem ÖGK-
Beitragstarifsystem, `.additional_docs/oegk/`, jeweils
parametrisiert zu pflegen). Fehlende BSGR-Sätze brechen den Lauf
listenweise ab (keine Erfindung von Tarifen).
- **Abschläge als eigene Verrechnungspositionen** (D.60, verifiziert):
A01/A02/A03 = Minderung AV auf 2/1/0 % (§ 2a AMPFG, DN-seitig —
Abschlag = AB × (2,95 Zielsatz) %), A09 = UV-Entfall ab 60
(1,10 %), A10 = AV+IE-Entfall Pensionsanspruch (6,00 %), A15 =
PV-Halbierung Bonusphase (11,40 %). A02/A10 schließen einander aus
(Konkurrenz a4: Entfall vor Minderung — wie der GP4-Kern rechnet).
- **BV (BMSVG)**: eigene Verrechnungsbasis ``BV`` (ungedeckelt, GP5)
+ Position ``V01`` (1,53 %); SZ-Basis ``SZ`` (§-54-gedeckelt) +
Position ``T02`` mit dem SZ-Gesamtsatz.
- **RSUM aus Basis × Satz** (mBGM-eigene Tarifierung); Konsistenz
gegen die GP4-Payslip-Summen als Prüfwarnung (Toleranz 0,10 €).
- **Fehlanzeige**: Paket ohne mBGM-Sätze (ANZM 0) für Beitragsmonate
ohne meldbare Daten (GP5-Deferral; DM-ORG-Form ⚠ Parallellauf
verifizieren). **Storno**: R1-Sätze mit REFU + identischem BZRM
(VSUM = gestornierte VSUM) für den Nachtragslauf; Nachreichung als
neue Deklaration (GP4-Deferral Aufrollung).
- Fallweise Beschäftigte (G3/G4 mit tagesgenauen Tarifblöcken) und
Geringfügige ohne Tarifparameter: dokumentierter Rest — der Lauf
bricht listenweise ab, bis `at_mbgm_tarif` gepflegt ist.
Dateibasiert (Prämisse P7): Generierung + Prüfprotokoll; Übertragung
beim Mandanten. Fristen: Selbstabrechner 15., Vorschreibebetriebe 7.
des Folgemonats (lb-mel-08) — Fristenkalender GP8."""
from datetime import date, timedelta
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,
l10n_at_euro_cent,
)
from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l16 import \
l10n_at_namen
# Paket (E.32, Selbstabrechner PS) — nach IDTEIL, Länge 305.
MBGM_PAKET = [
(2, 'REFP', 21, 40, 'a'),
(3, 'BKNR', 61, 10, 'a'),
(4, 'DGNA', 71, 70, 'a'),
(5, 'MPKE', 141, 30, 'a'),
(6, 'JAGB', 171, 1, 'a'),
(7, 'DTEL', 172, 50, 'a'),
(8, 'MAIL', 222, 60, 'a'),
(9, 'BZRM', 282, 6, 'n'), # MMJJJJ
(10, 'GSVZ', 288, 1, 'a'),
(11, 'GSUM', 289, 11, 'n'), # Σ VSUM (Cent)
(12, 'ANZM', 300, 6, 'n'), # Anzahl mBGM (G/R-Sätze)
]
# mBGM-Satz (E.32, G1 Selbstabrechner) — nach IDTEIL, Länge 326.
MBGM_SATZ = [
(2, 'REFW', 21, 40, 'a'),
(3, 'REFU', 61, 40, 'a'),
(4, 'REFV', 101, 40, 'a'),
(5, 'VSNR', 141, 10, 'n'),
(6, 'FANA', 151, 70, 'a'),
(7, 'VONA', 221, 70, 'a'),
(8, 'VSUM', 291, 11, 'n'), # Σ RSUM (Cent)
(9, 'VERG', 302, 1, 'a'), # 1 = SV+BV mit Versicherungszeit
(10, 'INF1', 303, 12, 'a'),
(11, 'INF2', 315, 12, 'a'),
]
# Tarifblock (E.32, T1) — nach IDTEIL, Länge 42.
MBGM_TARIFBLOCK = [
(2, 'BSGR', 21, 4, 'a'), # Beschäftigtengruppe (SV-Tarifsystem)
(3, 'ERGB', 25, 15, 'a'), # 5 × 3-stellige Ergänzungen
(4, 'VVON', 40, 2, 'n'), # Beginn der Verrechnung (Tag)
(5, 'KEUE', 42, 1, 'a'), # enthält KE/UE
]
# Verrechnungsbasis (E.32, BS) — nach IDTEIL, Länge 33.
MBGM_BASIS = [
(2, 'VBTY', 21, 2, 'a'), # AB/SZ/BV/UU …
(3, 'VBBT', 23, 11, 'n'), # Cent
]
# Verrechnungsposition (E.32, V1) — nach IDTEIL, Länge 42.
MBGM_POSITION = [
(2, 'VPTY', 21, 3, 'a'), # T01/T02/V01/A01…A15/Z01 …
(3, 'VPVZ', 24, 1, 'a'), # Vorzeichen Prozentsatz
(4, 'VPTA', 25, 6, 'n'), # Prozentsatz ×1000 (3 Nachkommastellen)
(5, 'RSVZ', 31, 1, 'a'), # Vorzeichen RSUM
(6, 'RSUM', 32, 11, 'n'), # Cent
]
class L10nAtPayrollMbgm(models.Model):
_name = 'l10n.at.payroll.mbgm'
_description = 'Monatliche Beitragsgrundlagenmeldung (mBGM/JASV)'
_inherit = ['mail.thread']
_order = 'beitragsmonat desc, id desc'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
beitragsmonat = fields.Date(
required=True, string='Beitragsmonat',
help='Erster Tag des Beitragsmonats (MMJJJJ).')
art = fields.Selection(
[('MB', 'mBGM (regulär)'),
('ST', 'Storno (R-Sätze, iVm REFU)'),
('FZ', 'Fehlanzeige (Paket ohne mBGM-Sätze)')],
required=True, default='MB', tracking=True)
storno_id = fields.Many2one(
'l10n.at.payroll.mbgm', string='Stornierte Deklaration',
help='Art ST: Referenz auf die ursprüngliche Deklaration; '
'REFU der R-Sätze = REFW der stornierten mBGM je AN.')
line_ids = fields.One2many(
'l10n.at.payroll.mbgm.line', 'mbgm_id', string='mBGM-Sätze')
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)
_sql_constraints = [(
'mbgm_company_monat_art_uniq',
'unique(company_id, beitragsmonat, art)',
'Für diesen Beitragsmonat existiert bereits eine mBGM-'
'Deklaration dieser Art.')]
def _l10n_at_slips(self):
self.ensure_one()
erster = self.beitragsmonat
letzter = (date(erster.year, erster.month, 28)
+ timedelta(days=4)).replace(day=1) \
- timedelta(days=1)
return self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('date_from', '>=', erster),
('date_from', '<=', letzter),
('state', 'in', ('validated', 'paid')),
])
def _l10n_at_slip(self, employee):
"""Der (eindeutige) Lauf des AN im Beitragsmonat."""
self.ensure_one()
erster = self.beitragsmonat
letzter = (date(erster.year, erster.month, 28)
+ timedelta(days=4)).replace(day=1) \
- timedelta(days=1)
return self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('employee_id', '=', employee.id),
('date_from', '>=', erster),
('date_from', '<=', letzter),
('state', 'in', ('validated', 'paid'))], limit=1)
def _l10n_at_bsgr(self, version, slip):
"""Beschäftigtengruppe (SV-Tarifsystem): B001 Arbeiter, B002
Angestellte; Lehrlinge B044/B045 und geringfügig/fallweise
(B010/B030) ohne Default-Parameter → Lauf bricht ab, bis
`at_mbgm_tarif` gepflegt ist (Tarifsystem-Werte, keine
Erfindung)."""
if version.l10n_at_lehrling:
return 'B044' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else 'B045'
if slip.l10n_at_geringfuegig or version.l10n_at_fallweise:
return 'B030'
return 'B001' if getattr(
version, 'l10n_at_personalgruppe', '') == 'arbeiter' \
else 'B002'
def _l10n_at_tarif(self, bsgr):
self.ensure_one()
tarif = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_mbgm_tarif', self.beitragsmonat,
raise_if_not_found=False) or {}
return tarif.get(bsgr)
def _l10n_at_positionen(self, slip, tarif):
"""Verrechnungspositionen je Tarifblock: T01 (Gesamtsatz,
NSchAB-Zweig via Company-Flag) + Abschläge aus den GP4-
Stichtagsflags; T02 auf SZ; V01 auf BV-Basis. Rückgabe:
[(vpty, vpta, rsum_cent, basis_typ), …] — RSUM aus Basis ×
Satz (mBGM-Tarifierung); Abschläge negativ (RSVZ '-')."""
version = slip.version_id
ab = sum(basis for _m, basis
in slip._l10n_at_sv_basis_laufen_monate())
nschab = 3.80 if (self.company_id.l10n_at_nschab
and not version.l10n_at_lehrling
and getattr(version, 'l10n_at_personalgruppe',
'') == 'arbeiter') else 0.0
satz_laufen = float(tarif['laufend']) + nschab
positionen = [('T01', satz_laufen, ab * satz_laufen / 100.0,
'AB')]
# §-2a-AMPFG: Minderung nur des DN-AV (2,95 %), als eigene
# Abschlagsposition auf JEDER betroffenen Verrechnungsbasis
# (AB und SZ — der Abschlag senkt den Gesamtsatz je Basis;
# DN-seitig, GP4-Konvention). Codes: 0 % → A03, 1 % → A02,
# 2 % → A01 (D.60, verifiziert).
minderung = 0.0
monat = self.beitragsmonat
if not (version.l10n_at_alv_entfall_ab
and monat >= version.l10n_at_alv_entfall_ab):
staffel = self.env['hr.rule.parameter'] \
._get_parameter_from_code(
'at_sv_av_staffel_ampfg', monat,
raise_if_not_found=False) or {}
for _von, bis, dn_satz in staffel.get('tiers', []):
if ab and ab <= float(bis):
minderung = 2.95 - float(dn_satz)
break
if minderung > 0:
code = ('A03' if minderung >= 2.95
else 'A02' if abs(minderung - 1.95) < 0.01
else 'A01')
positionen.append(
(code, 0.0, -ab * minderung / 100.0, 'AB'))
else:
# A10 = Entfall AV+IE (DN-AV + DG-AV + DG-IESG).
positionen.append(
('A10', 0.0, -ab * 6.00 / 100.0, 'AB'))
if version.l10n_at_uv_entfall_ab \
and monat >= version.l10n_at_uv_entfall_ab:
positionen.append(('A09', 0.0, -ab * 1.10 / 100.0, 'AB'))
if version.l10n_at_pv_bonusphase_ab \
and monat >= version.l10n_at_pv_bonusphase_ab:
positionen.append(('A15', 0.0, -ab * 11.40 / 100.0, 'AB'))
sz_basis = slip._l10n_at_svdn_basis_sz()
if sz_basis > 0:
satz_sz = float(tarif['sz'])
positionen.append(
('T02', satz_sz, sz_basis * satz_sz / 100.0, 'SZ'))
if minderung > 0:
code = ('A03' if minderung >= 2.95
else 'A02' if abs(minderung - 1.95) < 0.01
else 'A01')
positionen.append(
(code, 0.0, -sz_basis * minderung / 100.0, 'SZ'))
bv_basis = (slip._at_bv_basis_laufen()
+ slip._at_bv_basis_sonst())
if bv_basis > 0:
positionen.append(('V01', 1.53, bv_basis * 0.0153, 'BV'))
return positionen
def action_erzeugen(self):
for deklaration in self:
fehler = []
warnungen = []
company = deklaration.company_id
bknr = company.l10n_at_beitragskontonummer
if not bknr:
fehler.append('Beitragskontonummer (BKNR) fehlt.')
vstr = (bknr or '')[:2]
deklaration.line_ids.unlink()
datensaetze = []
summe_gesamt = 0
anzahl = 0
if deklaration.art != 'FZ':
slips = deklaration._l10n_at_slips()
nach_an = {}
for slip in slips:
nach_an.setdefault(slip.employee_id, []).append(slip)
for employee, employee_slips in nach_an.items():
version = employee.version_id
if not (version and version.ssnid):
fehler.append(
'Mitarbeiter %s ohne Versicherungsnummer.'
% employee.name)
continue
if len(employee_slips) > 1:
fehler.append(
'Mitarbeiter %s mit mehreren Läufen im '
'Beitragsmonat — mBGM je Lauf ist ein '
'dokumentierter Rest.' % employee.name)
continue
slip = employee_slips[0]
if deklaration.art == 'ST':
# Storno: R1-Satz je stornierter mBGM mit
# REFU = REFW des Originals, VSUM wie Original
# (E.32.2.8 — ohne T-/BS-/V-Sätze).
original = deklaration.storno_id.line_ids \
.filtered(lambda l: l.employee_id == employee)
if not original:
fehler.append(
'Storno %s: keine stornierte mBGM '
'am Mitarbeiter gefunden.' % employee.name)
continue
refw = 'MBGM-ST-%d-%s-%d' % (
company.id,
deklaration.beitragsmonat.strftime('%Y%m'),
employee.id)
storno_satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326,
bezeichnung='Storno %s' % employee.name)
storno_satz.setze('SART', 'R1')
storno_satz.setze('REFW', refw)
storno_satz.setze('REFU', original.refw)
storno_satz.setze('VSNR', version.ssnid)
storno_satz.setze('VSUM', abs(original.vsum),
cent=True)
datensaetze.append(storno_satz)
deklaration.env['l10n.at.payroll.mbgm.line']\
.create({
'mbgm_id': deklaration.id,
'employee_id': employee.id,
'versicherungsnummer': version.ssnid,
'bsgr': original.bsgr,
'refw': refw,
'vsum': original.vsum,
})
summe_gesamt += original.vsum
anzahl += 1
continue
bsgr = deklaration._l10n_at_bsgr(version, slip)
tarif = deklaration._l10n_at_tarif(bsgr)
if not tarif:
fehler.append(
'Mitarbeiter %s: kein Tarifsatz für BSGR '
'%s parametrisiert (at_mbgm_tarif, Werte '
'aus dem SV-Tarifsystem pflegen).'
% (employee.name, bsgr))
continue
if slip.l10n_at_geringfuegig \
or version.l10n_at_fallweise:
fehler.append(
'Mitarbeiter %s: geringfügige/fallweise '
'Beschäftigung in der mBGM ist ein '
'dokumentierter Rest (BSGR %s, Tarifsystem '
'verifizieren).' % (employee.name, bsgr))
continue
positionen = deklaration._l10n_at_positionen(
slip, tarif)
vsum = sum(rsum for _c, _s, rsum, _b in positionen)
# Konsistenz gegen die GP4-Payslip-Summen.
ist = (slip._l10n_at_svdn_gesamt()
+ slip._l10n_at_svdg_gesamt()
+ slip._l10n_at_svdn_sz()
+ sum(
slip._l10n_at_svdg_sz(k)
for k in ('kv', 'pv', 'av', 'uv', 'iesg'))
+ slip._at_bv_beitrag())
if abs(vsum - ist) > 0.10:
warnungen.append(
'Mitarbeiter %s: mBGM-VSUM %.2f weicht um '
'%.2f von den GP4-Beitragssummen ab '
'(Tarif-/Rundungsdifferenz prüfen).'
% (employee.name, vsum, vsum - ist))
deklaration.env['l10n.at.payroll.mbgm.line'].create({
'mbgm_id': deklaration.id,
'employee_id': employee.id,
'versicherungsnummer': version.ssnid,
'bsgr': bsgr,
'refw': 'MBGM-%d-%s-%d' % (
company.id,
deklaration.beitragsmonat.strftime('%Y%m'),
employee.id),
'vsum': vsum,
})
datensaetze.extend(
deklaration._l10n_at_mbgm_datensaetze(
employee, version, bsgr, positionen, vsum))
summe_gesamt += vsum
anzahl += 1
paket = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_PAKET, 326,
bezeichnung='mBGM-Paket')
paket.setze('SART', 'PS')
paket.setze('VSTR', vstr)
paket.setze('REFP', 'MBGM-%d-%s' % (
company.id,
deklaration.beitragsmonat.strftime('%Y%m')))
paket.setze('BKNR', bknr)
paket.setze('DGNA', company.name[:70])
paket.setze('JAGB', 'N')
paket.setze('BZRM', deklaration.beitragsmonat.strftime(
'%m%Y'))
paket.setze('GSVZ', '+' if summe_gesamt >= 0 else '-')
paket.setze('GSUM', abs(summe_gesamt), cent=True)
paket.setze('ANZM', anzahl)
datensaetze.insert(0, paket)
if fehler:
deklaration.protokoll = 'FEHLER:\n%s\nWARNUNGEN:\n%s' % (
'\n'.join(fehler), '\n'.join(warnungen) or '')
raise UserError('mBGM-Lauf mit Fehlern abgebrochen:\n'
+ '\n'.join(fehler))
bestand = L10nAtEldaBestand(
'MB', vstr, '02',
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])
deklaration.write({
'datei': base64.b64encode(datei.render()),
'datei_name': 'mbgm_%s.txt' % (
deklaration.beitragsmonat.strftime('%Y%m'),),
'protokoll': 'WARNUNGEN:\n%s' % (
'\n'.join(warnungen) or ''),
'state': 'done',
})
return True
def _l10n_at_mbgm_datensaetze(self, employee, version, bsgr,
positionen, vsum):
"""G1 + Tarifblock + je Basis (AB/SZ/BV) die Positionen als
eigene Sätze (BS/V folgen hierarchisch dem Tarifblock)."""
self.ensure_one()
slip = self._l10n_at_slip(employee)
mbgm_satz = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_SATZ, 326,
bezeichnung='mBGM %s' % employee.name)
mbgm_satz.setze('SART', 'G1')
mbgm_satz.setze('REFW', 'MBGM-%d-%s-%d' % (
self.company_id.id,
self.beitragsmonat.strftime('%Y%m'), employee.id))
mbgm_satz.setze('VSNR', version.ssnid)
familienname, vorname = l10n_at_namen(employee)
mbgm_satz.setze('FANA', familienname[:70])
mbgm_satz.setze('VONA', (vorname or '')[:70])
mbgm_satz.setze('VSUM', abs(vsum), cent=True)
mbgm_satz.setze('VERG', '1')
tarifblock = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_TARIFBLOCK, 326,
bezeichnung='Tarifblock %s' % employee.name)
tarifblock.setze('SART', 'T1')
tarifblock.setze('BSGR', bsgr)
tarifblock.setze('VVON', '1')
saetze = [mbgm_satz, tarifblock]
for code, satz_prozent, rsum, basis_typ in positionen:
basis = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_BASIS, 326,
bezeichnung='Basis %s %s' % (basis_typ,
employee.name))
basis.setze('SART', 'BS')
basis.setze('VBTY', basis_typ)
basis_wert = abs(rsum) * 100.0 / satz_prozent \
if satz_prozent else 0.0
if basis_typ == 'AB':
basis_wert = sum(
b for _m, b in
slip._l10n_at_sv_basis_laufen_monate())
elif basis_typ == 'SZ':
basis_wert = slip._l10n_at_svdn_basis_sz()
else:
basis_wert = slip._at_bv_basis_laufen() \
+ slip._at_bv_basis_sonst()
basis.setze('VBBT', basis_wert, cent=True)
position = L10nAtEldaSatz(
L10N_AT_ELDA_IDTEIL + MBGM_POSITION, 326,
bezeichnung='Position %s %s' % (code, employee.name))
position.setze('SART', 'V1')
position.setze('VPTY', code)
position.setze('VPTA', satz_prozent, skala=3)
position.setze('RSVZ', '-' if rsum < 0 else '+')
position.setze('RSUM', abs(rsum), cent=True)
saetze.extend([basis, position])
return saetze
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_at_nschab = fields.Boolean(
string='AT Schlechtwetterentschädigungsbeitrag-Branche',
help='Betrieb mit Schlechtwetterentschädigung (ÖGK-'
'Tarifsystem „Normalbetriebe mit '
'Schlechtwetterentschädigung“): Arbeiter (BSGR B001) '
'erhalten im mBGM-T01 den um NSchAB 3,80 % erhöhten '
'Gesamtsatz.')
class L10nAtPayrollMbgmLine(models.Model):
_name = 'l10n.at.payroll.mbgm.line'
_description = 'mBGM — Satzdaten je AN'
_order = 'employee_name'
mbgm_id = fields.Many2one(
'l10n.at.payroll.mbgm', required=True, ondelete='cascade',
index=True)
employee_id = fields.Many2one('hr.employee', required=True)
employee_name = fields.Char(related='employee_id.name', store=True,
readonly=True)
versicherungsnummer = fields.Char()
bsgr = fields.Char(string='Beschäftigtengruppe')
refw = fields.Char(string='Referenzwert (REFW)')
vsum = fields.Monetary(
string='VSUM', currency_field='currency_id')
currency_id = fields.Many2one(
'res.currency', related='mbgm_id.company_id.currency_id')
@@ -0,0 +1,406 @@
# 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