# 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)/03–04 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, timedelta import base64 from odoo import api, fields, models from odoo.exceptions import UserError from odoo.addons.l10n_at_hr_payroll_private.models.karenz import \ KARENZ_ARTEN 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_offene_karenz_events(self): """AP16 (8.13): An-/Abmeldungen aus validierten Karenz- Freistellungen: M4 zum Ende des Entgeltanspruchs (Tag vor dem Karenzbeginn; Familienzeit am Tag vor Beginn, § 11 Abs 3 lit a ASVG, lb-pap-01), M3 zum ersten Arbeitstag nach dem Karenzende — entfällt, wenn das DV mit der Karenz endet (normale M4- Vertragsabmeldung deckt das Ende). Karenzart-Strings für die eSV-Sonderformate „Familienhospizkarenz/Pflegekarenz An-/ Abmeldung" (lb-kzs-05) laufen als Protokoll-Info; erweiterte eSV-Sonderformate = GP7-Rest. Dedup über die Registry (employee, art, adatum); keine Events nach dem Stichtag.""" self.ensure_one() typen = self.env['hr.leave.type'].search([ ('work_entry_type_id.code', 'in', list(KARENZ_ARTEN))]) if not typen: return [] leaves = self.env['hr.leave'].search([ ('company_id', 'in', self.company_id.ids), ('holiday_status_id', 'in', typen.ids), ('state', 'in', ('validate', 'validate1')), ]).sorted('request_date_from') registry = self.env['l10n.at.payroll.vr.event'].search([ ('company_id', '=', self.company_id.id), ]) gesehen = {(e.employee_id.id, e.art, e.adatum) for e in registry} ereignisse = [] for leave in leaves: von = leave.request_date_from bis = leave.request_date_to if not von or not bis: continue version = leave.employee_id.version_id if not version: continue wett_code = leave.holiday_status_id \ .work_entry_type_id.code karenzart = KARENZ_ARTEN.get(wett_code, 'Karenz') abmeldetag = von - timedelta(days=1) if abmeldetag > self.stichtag: continue if (leave.employee_id.id, 'M4', abmeldetag) not in gesehen: ereignisse.append((version, 'M4', abmeldetag, karenzart)) rueckkehr = bis + timedelta(days=1) if (version.contract_date_end and rueckkehr >= version.contract_date_end): continue if rueckkehr > self.stichtag: continue if (leave.employee_id.id, 'M3', rueckkehr) not in gesehen: ereignisse.append((version, 'M3', rueckkehr, karenzart)) return ereignisse 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 = [] karenz_info = [] 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)) for version, art, adatum, karenzart in \ lauf._l10n_at_offene_karenz_events(): if not version.ssnid: fehler.append( 'Karenz-Meldung %s ohne Versicherungsnummer.' % version.employee_id.name) continue satz = L10nAtEldaSatz( L10N_AT_ELDA_IDTEIL + VR_SATZ, 772, bezeichnung='%s Karenz %s' % ( art, version.employee_id.name)) refw = '%s-K%d-%s-%d' % ( art, company.id, adatum.strftime('%Y%m%d'), version.employee_id.id) satz.setze('SART', art) 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', adatum) if art == 'M3': satz.setze('BBER', lauf._l10n_at_bber(version)) satz.setze('GERF', 'N') satz.setze('FRDV', 'N') else: satz.setze_datum('EBSV', adatum) satz.setze('AGRD', '00') kalender = version.resource_calendar_id if kalender: satz.setze('VWAZ', kalender.hours_per_week, skala=2) datensaetze.append(satz) ereignisse.append((version, art, adatum, refw)) karenz_info.append( '%s (ADAT %s) — %s' % ( version.employee_id.name, adatum.strftime('%d.%m.%Y'), karenzart)) 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'] protokoll = 'Meldungen: %d' % len(datensaetze) if karenz_info: protokoll += ("\nKarenz (eSV-Sonderformate GP7-Rest; " "Karenzart zur Übergabe):\n" + "\n".join(karenz_info)) 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': protokoll, }) return True