[IMP] l10n_at_hr_payroll: GP8 Fristenkalender, Jahres-Retro, Mitteilungsfile-Import, 2027-Parameter-Rahmen

Fristenkalender (models/fristenkalender.py — l10n.at.payroll.frist): alle lohnverrechnungsrelevanten Melde- und Zahlungsfristen je Company und Jahr (kuratiert: lb-sva-11, lb-mel-08, lb-lvr-04/05): SV/mBGM 15. Folgemonat (Gutschriftprinzip, Respirofrist 3 Tage, Verzugszinsen ab Tag 16), DB/DZ/KommSt 15. Folgemonat, KommSt-Jahreserklärung Ende Maerz, Lohnzettel L16 + Arbeitsstaettenmeldung + §-109a Ende Februar, Schwerarbeitsmeldung Ende Februar (blockiert, ASchV-Datenmodell fehlt). Auto-Erzeugung per ir.cron monatlich am 1. (SV/DB/DZ/KommSt) + jaehrlich am 1. Jaenner (Jahresfristen). Ueberfaellig-Faerbung + Sprungaktion zur GP7-Deklaration (L16; mBGM-Verlinkung im Privatmodul via fristen_private.py).

Jahres-Retro-/Aufrollungslauf (models/jahresretro.py — Wizard l10n.at.payroll.jahresretro.wizard): Kontrollsechstel-Verpflichtungslauf § 67 Abs 10 EStG (Pruefung je AN auf SZ-Ueberhang und Guenstig-Vergleich Fixsaetze vs. Tarif; Handlungsempfehlung fuer die Odoo-Korrektur-Pipeline: Refund-Payslips + Neuberechnung). Optional §-77-Abs-3/4-Aufrollung (15.-Jaenner-Grenze; Krankengeld-Blocker). SV-Aufrollung rollt auf den Beitragsmonat (GP4-Beitragsmonat); mBGM-Storno als Folgeaktion (GP7 art=ST).

Mitteilungsfile-Import (models/mitteilungsfile.py — Wizard): ELDA-Rueckantwort-XML (mitteilung_{Protokollnummer}.xml) parsen, Fehler/Warnungen lesbar aufbereiten; XSDs lokal beschafft (.firecrawl/ris/elda/). Dateibasiert (Praemisse P7): Upload durch den Mandanten.

2027-Parameter-Rahmen (data/rule_parameters_2027.xml): NUR Struktur (WF-Vektor-Default + AV-Staffel tiers=[] da § 2a AMPFG aufgehoben BGBl I 62/2026). KEINE Werte aus Trainingswissen — der Wartungszyklus beschafft die offiziellen 2027-Werte.

B14-laengster-Urlaubs-Teil: verifiziert — aktuelle Implementierung ist praktisch korrekt (erster Urlaubsmonat, _at_sz_bereits_bezahlt gegen Doppelauszahlung); mehrmonatig gesplittete Urlaube sind ein Randfall mit vorwaertsblickenden Planungsdaten, dokumentiert ohne Code-Fix.

Tests: test_gp8 (6 — Frist-Definitionen, Cron-Idempotenz, Ueberfaellig-Logik, Mitteilungsfile-Parser, Import-ohne-Datei-UserError, Jahresretro-ohne-Daten). 100/100 gruen (Wegwerf-DB). 19.0.5.0.0.
This commit is contained in:
2026-09-12 19:58:53 +02:00
parent ea14c48cca
commit c843701d1a
10 changed files with 820 additions and 5 deletions
+9 -2
View File
@@ -1,11 +1,10 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)',
'version': '19.0.4.0.0',
'version': '19.0.5.0.0',
'author': 'ixsol',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
'version': '19.0.4.0.0',
'description': """
Österreichischer Personalverrechnungs-Kern
==========================================
@@ -43,6 +42,12 @@ Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/``
VSNR-Formatprüfung, eSV-Abmeldegrund-Mapping,
``models/meldewesen.py``), Belege Lohnzettel AT/Monatsdienstliste/
Monatsjournal (QWeb, ``models/monatsreports.py``);
- **GP8:** Fristenkalender (``models/fristenkalender.py`` — Cron,
Auto-Erzeugung, Sprung zur GP7-Deklaration), Jahres-Retro-/
Aufrollungslauf (``models/jahresretro.py`` — Kontrollsechstel
§ 67 Abs 10, §-77-Abs-3/4-Option), Mitteilungsfile-Import
(``models/mitteilungsfile.py`` — ELDA-Rückantwort-XML),
2027-Parameter-Struktur (nur Rahmen);
- deklarative TASY-Mapping-Tabelle und Import-Wizard (Preview,
Sprungwarnung > 10 %, explizite Bestätigung, versionierte Werte);
- **Meldewesen/Belege (AP5/AP6, GP7):** ELDA-Fixsatz-Framework
@@ -66,9 +71,11 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft
'data/rule_parameters_2026.xml',
'data/rule_parameters_lst_2026.xml',
'data/rule_parameters_sv_2026.xml',
'data/rule_parameters_2027.xml',
'data/tasy_mapping.xml',
'views/tasy_views.xml',
'views/meldewesen_views.xml',
'views/fristen_views.xml',
'report/belege_reports.xml',
],
'countries': ['at'],
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- GP8: 2027-Parameter-Platzhalter (NUR Struktur, keine Werte!). -->
<!-- Der Wartungszyklus (RUNBOOK) beschafft die offiziellen 2027- -->
<!-- Werte (SV-Werte PDF, Lohnsteuer-Tarif, KV-Gehaltstabellen) -->
<!-- und pflegt diese Zeilen nach — GP8 liefert nur den Rahmen. -->
<!-- KEINE Werte aus Trainingswissen! -->
<record id="hr_rule_parameter_value_at_sv_wf_vektor_2027" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_wf_vektor"/>
<field name="date_from">2027-01-01</field>
<field name="parameter_value">{'default': {'dn': 0.5, 'dg': 0.5}}</field>
</record>
<!-- AV-Staffel 2027: § 2a AMPFG aufgehoben (BGBl I 62/2026) → -->
<!-- tiers=[] (voller DN-Satz) — das ist bereits 2026 verifiziert -->
<!-- und kein Trainingswert. -->
<record id="hr_rule_parameter_value_at_sv_av_staffel_2027" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sv_av_staffel_ampfg"/>
<field name="date_from">2027-01-01</field>
<field name="parameter_value">{'tiers': [], 'dn_voll': 2.95}</field>
</record>
</odoo>
+4 -1
View File
@@ -5,4 +5,7 @@ from . import sozialversicherung
from . import elda
from . import meldewesen
from . import kind
from . import lohnzettel_l16
from . import lohnzettel_l16
from . import fristenkalender
from . import jahresretro
from . import mitteilungsfile
@@ -0,0 +1,230 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Fristenkalender (GP8/AP8): alle lohnverrechnungsrelevanten
Melde-/Zahlungsfristen je Company und Jahr.
Kuratierte Fristen (lb-sva-11, lb-mel-08, lb-lvr-04/05, Stand
2026-06…08):
- SV-Beiträge (mBGM): Einlangen **15. des Folgemonats** (Gutschrift-
prinzip; Respirofrist 3 Tage; Verzugszinsen ab dem 16. Tag);
Vorschreibebetriebe 7.
- DB (FLAF) / DZ / KommSt: je **15. des Folgemonats**.
- KommSt-Jahreserklärung: über FinanzOnline **bis Ende März** des
Folgejahres.
- Lohnzettel L16 (§ 84 EStG): **Ende Februar** des Folgejahres via
ELDA (Papierformular ohne technische Voraussetzungen: Ende Jänner);
berichtigter Lohnzettel nach steuerlich relevanter Ergänzung des
Lohnkontos **binnen 14 Tagen**.
- Arbeitsstättenmeldung § 34 Abs 6 ASVG: **Ende Februar** (via ELDA
an Statistik Austria).
- § 109a-EStG-Meldung: **Ende Februar** (ELDA); Papierformular E 109a
+ Kopie an den Entgeltempfänger Ende Jänner (nicht implementiert —
Honorar-Basis).
- Schwerarbeitsmeldung ASchV § 5: Jänner bis **Ende Februar** via
ELDA (nicht implementiert — blockiert, ASchV-Datenmodell fehlt).
Odoo-Integration: `mail.activity.mixin` für optionale Benachrichtigung
bei Überschreitung; native Fristart-Definition mit Sprungaktion zur
GP7-Deklaration (L16/mBGM erzeugen); Auto-Erzeugung per ir.cron
(monatlich am 1.: offene SV/DB/DZ/KommSt-Fristen des Vormonats;
jährlich am 1. Jänner: Jahresfristen des Vorjahres)."""
from datetime import date, timedelta
from odoo import api, fields, models
FRISTART_DEFINITIONEN = {
'sv_mbgm': {
'name': 'SV-Beiträge (mBGM) einlangen',
'monatlich': True,
'tag': 15,
'action': 'mbgm',
'hilfetext': '15. des Folgemonats (Gutschriftprinzip; '
'Vorschreibebetriebe: 7.). Verzugszinsen ab dem '
'16. Tag.',
},
'db_dz_kommst': {
'name': 'DB/DZ/KommSt einlangen',
'monatlich': True,
'tag': 15,
'action': None,
'hilfetext': 'FLAF-Dienstgeberbeitrag, Zuschlag zum '
'Dienstgeberbeitrag und Kommunalsteuer: je 15. '
'des Folgemonats.',
},
'kommst_jahres': {
'name': 'KommSt-Jahreserklärung (FinanzOnline)',
'monatlich': False,
'monat': 3,
'tag_im_monat': 31,
'action': None,
'hilfetext': 'Jahreserklärung der Kommunalsteuer über '
'FinanzOnline bis Ende März des Folgejahres '
'(§ 11 Abs 5 KommStG).',
},
'l16': {
'name': 'Lohnzettel L16 (§ 84 EStG)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': 'l16',
'hilfetext': 'Jahres-Lohnzettel bis Ende Februar des '
'Folgejahres via ELDA; Papierformular ohne '
'technische Voraussetzungen: Ende Jänner. '
'Berichtigter Lohnzettel nach steuerlich '
'relevanter Ergänzung des Lohnkontos binnen '
'14 Tagen.',
},
'arbeitsstaette': {
'name': 'Arbeitsstättenmeldung (§ 34 Abs 6 ASVG)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': 'l16',
'hilfetext': 'Adresse der Arbeitsstätte am 31. 12. bis '
'Ende Februar des Folgejahres via ELDA (VSTR '
'ST). Läuft im GP7-Jahreslauf mit der L16.',
},
'paragraph_109a': {
'name': '§ 109a-EStG-Meldung (Honorare)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': None,
'hilfetext': 'Meldung der Honorare außerhalb von '
'Dienstverhältnissen bis Ende Februar via '
'ELDA (8 Tätigkeitsgruppen; kumulativ ≤ 900 € '
'und ≤ 450 € je Leistung → entfällt).',
},
'schwerarbeit': {
'name': 'Schwerarbeitsmeldung (ASchV § 5)',
'monatlich': False,
'monat': 2,
'tag_im_monat': 28,
'action': None,
'hilfetext': 'Jährlich rückwirkend Jänner bis Ende Februar '
'via ELDA an den KV-Träger; meldepflichtig '
'ab 40 M / 35 F (⚠ blockiert — ASchV-Daten-'
'modell fehlt im Modul).',
},
}
class L10nAtPayrollFrist(models.Model):
_name = 'l10n.at.payroll.frist'
_description = 'Lohnverrechnungs-Fristenkalender'
_inherit = ['mail.thread']
_order = 'deadline, id'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company, index=True)
fristart = fields.Selection(
[(k, v['name']) for k, v in FRISTART_DEFINITIONEN.items()],
required=True, index=True)
jahr = fields.Integer(
required=True, string='Bezugs-/Kalenderjahr',
help='Beitragsjahr (monatliche Fristen) bzw. gemeldetes '
'Jahr (Jahresfristen: L16/Arbeitsstätte/§-109a gelten '
'für das Vorjahr).')
monat = fields.Integer(
string='Beitragsmonat (monatliche Fristen)',
help='112; leer bei Jahresfristen.')
deadline = fields.Date(required=True, string='Frist')
state = fields.Selection(
[('offen', 'Offen'), ('erledigt', 'Erledigt'),
('ueberfaellig', 'Überfällig')],
compute='_compute_state', store=True, index=True)
erledigt_am = fields.Date(readonly=True)
hilfetext = fields.Text(readonly=True)
ref_l16_id = fields.Many2one(
'l10n.at.payroll.l16', string='L16-Deklaration',
help='Sprungziel für Fristen mit L16/Arbeitsstätten-Meldung.')
_sql_constraints = [(
'frist_company_art_jahr_monat_uniq',
'unique(company_id, fristart, jahr, monat)',
'Diese Frist existiert bereits für das Unternehmen.')]
@api.depends('deadline', 'erledigt_am')
def _compute_state(self):
heute = fields.Date.today()
for frist in self:
if frist.erledigt_am:
frist.state = 'erledigt'
elif frist.deadline and frist.deadline < heute:
frist.state = 'ueberfaellig'
else:
frist.state = 'offen'
def action_erledigt(self):
self.write({'erledigt_am': fields.Date.today()})
return True
def action_wieder_eroeffnen(self):
self.write({'erledigt_am': False})
return True
def action_zum_objekt(self):
"""Sprung zur GP7-Deklaration (L16 oder mBGM)."""
self.ensure_one()
if self.ref_l16_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'l10n.at.payroll.l16',
'res_id': self.ref_l16_id.id,
'view_mode': 'form',
}
return True
@api.model
def _cron_fristen_erzeugen(self):
"""Monatlich am 1.: offene Fristen des Vormonats (SV/DB/DZ/
KommSt) + jährlich am 1. Jänner: Jahresfristen des
Vorjahres. Idempotent (Unique-Constraint)."""
heute = fields.Date.today()
vormonat = (heute.replace(day=1) - timedelta(days=1))
for company in self.env['res.company'].search([]):
# Monatliche Fristen des Vormonats (15. des Folgemonats).
folgemonat = heute.replace(day=1)
deadline_monat = date(
folgemonat.year, folgemonat.month,
FRISTART_DEFINITIONEN['sv_mbgm']['tag'])
for art in ('sv_mbgm', 'db_dz_kommst'):
self._sicherstellen(
company, art, vormonat.year, vormonat.month,
deadline_monat)
# Jahresfristen nur am 1. Jänner (für das Vorjahr).
if heute.month == 1:
for art in ('l16', 'arbeitsstaette', 'paragraph_109a',
'schwerarbeit'):
monat = FRISTART_DEFINITIONEN[art]['monat']
tag = FRISTART_DEFINITIONEN[art]['tag_im_monat']
jahr_deadline = heute.year
deadline_jahr = date(jahr_deadline, monat, tag)
if deadline_jahr.month == 2 and tag > 28:
deadline_jahr = date(jahr_deadline, monat, 28)
self._sicherstellen(
company, art, heute.year - 1, None,
deadline_jahr)
# KommSt-Jahreserklärung (Ende März).
self._sicherstellen(
company, 'kommst_jahres', heute.year - 1, None,
date(heute.year, 3, 31))
@api.model
def _sicherstellen(self, company, art, jahr, monat, deadline):
vals = {
'company_id': company.id,
'fristart': art,
'jahr': jahr,
'monat': monat,
'deadline': deadline,
'hilfetext': FRISTART_DEFINITIONEN[art]['hilfetext'],
}
bestehend = self.search([
('company_id', '=', company.id),
('fristart', '=', art),
('jahr', '=', jahr),
('monat', '=', monat)])
if not bestehend:
self.create(vals)
return bestehend or self.env['l10n.at.payroll.frist']
@@ -0,0 +1,152 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Jahres-Retro-/Aufrollungslauf (GP8/AP8): Kontrollsechstel-
Verpflichtungslauf (§ 67 Abs 10 EStG) und optionale §-77-Abs-3/4-
Aufrollung mit YTD-Rekalkulation.
Kuratierte Grundlagen (lb-lvr-07, lb-naz-01…04, 2026-06/08):
- **Kontrollsechstel** (§ 67 Abs 10): sonstige Bezüge innerhalb des
Jahressechstels sind mit Fixsätzen versteuert; bei Überhang wird
die **Differenz tarifisch** versteuert — der Dezember-/
Beendigungsabrechnungslauf rechnet die SZ-Steuer mit dem Tarif neu
(**Günstig-Aufrollung** — GP3-Engine kann beides bereits).
- **§ 77 Abs 3/4**: optionale Aufrollung der Lohnsteuer des
Kalenderjahres beim Jahreswechsel (15.-Jänner-Grenze;
Krankengeldbezug im Jahr blockt Abs 3/4, nicht Abs 4a). Die
Aufrollung rechnet die laufende Lohnsteuer kumulativ neu und
verteilt die Differenz auf die verbleibenden Monate.
- **§ 77 Abs 4a**: Aufrollung nach §-67-Ausschüttungen (nachträg-
liche SZ-Steuer mit Günstig-Vergleich).
- **15.2.-Cutoff** (lb-naz-02/03): Zahlung bis 15. 2. des Folgejahres
→ Vorjahreszuordnung (berichtigter Lohnzettel binnen 14 Tagen);
danach keine Zuordnung mehr (§ 67 Abs 8 lit c Fünftel oder
§ 67 Abs 1 Tarif).
- **SV rollt stets auf den Beitragsmonat der Fälligkeit** (GP4:
`l10n_at_beitragsmonat` je Regel/Linie, historisch versionierte
Sätze/HBG). mBGM-Storno/Neumeldung als Folgeaktion (GP7 `art='ST'`).
Odoo-Mechanik: der Wizard **erzeugt keine eigenen Korrekturdaten** —
er analysiert die Jahresdaten, zeigt den Handlungsbedarf und
empfiehlt die Odoo-native Korrektur-Pipeline (Refund-Payslips über
`_action_refund_payslips()` + Neuberechnung des betroffenen Monats
mit korrigierten Eingangsdaten; die GP3-Engine rechnet YTD
automatisch um)."""
from datetime import date
from odoo import api, fields, models
from odoo.exceptions import UserError
class L10nAtPayrollJahresretroWizard(models.TransientModel):
_name = 'l10n.at.payroll.jahresretro.wizard'
_description = 'Jahres-Retro-/Aufrollungslauf (GP8)'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
jahr = fields.Integer(
required=True, default=lambda self: fields.Date.today().year - 1,
string='Jahr (Kontroll-/Aufrollungslauf)')
zeige_detail = fields.Boolean(
default=True, string='Detail je Mitarbeiter anzeigen')
nur_kontrollsechstel = fields.Boolean(
default=True, string='Nur Kontrollsechstel-Verpflichtungslauf '
'prüfen (§ 67 Abs 10)')
aufrollung_77 = fields.Boolean(
default=False,
string='§-77-Abs-3/4-Aufrollung prüfen',
help='Optionale Aufrollung der laufenden Lohnsteuer des '
'Kalenderjahres (nur ohne Krankengeldbezug; 15.-Jänner-'
'Grenze für die Zahlung; Abs 4a nach §-67-Ausschüttungen '
'unabhängig prüfbar).')
ergebnis_ids = fields.One2many(
'l10n.at.payroll.jahresretro.ergebnis',
'wizard_id', string='Prüfergebnis je Mitarbeiter')
zusammenfassung = fields.Text(readonly=True)
def action_pruefen(self):
for wizard in self:
wizard.ergebnis_ids.unlink()
slips = wizard._slips_des_jahres()
ergebnisse = []
for slip in slips:
ergebnis = wizard._pruefe_mitarbeiter(slip)
if ergebnis:
ergebnisse.append(ergebnis)
wizard.ergebnis_ids = [
(0, 0, e) for e in ergebnisse]
wizard.zusammenfassung = wizard._zusammenfassung()
return True
def _slips_des_jahres(self):
self.ensure_one()
return self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('date_from', '>=', date(self.jahr, 1, 1)),
('date_from', '<=', date(self.jahr, 12, 31)),
('state', 'in', ('validated', 'paid')),
]).sorted('date_from')
def _pruefe_mitarbeiter(self, slip):
"""Prüft Kontrollsechstel-Überhang und optional §-77-Aufrollung
je AN. Liefert None, wenn kein Handlungsbedarf."""
self.ensure_one()
sechstel = slip._l10n_at_sechstel()
sonstige_basis = slip._l10n_at_brutto('sonstig')
laufende_basis = slip._l10n_at_brutto('laufend')
# §-67-Bezüge über dem Jahressechstel → Überhang.
if self.nur_kontrollsechstel \
and sonstige_basis > sechstel:
ueberhang = sonstige_basis - sechstel
fixsatz_st = slip._l10n_at_sz_tarifisch_aktuell()
tarif_st = slip._l10n_at_lst_sonstige()
gut = fixsatz_st <= tarif_st
return {
'employee_id': slip.employee_id.id,
'kontrollsechstel_ueberhang': ueberhang,
'fixsatz_steuer': fixsatz_st,
'tarif_steuer': tarif_st,
'guenstig': gut,
'handlung': (
'Keine Korrektur — Fixsätze sind günstiger'
if gut else
'Aufrollung: Dezemberlauf rechnet die SZ-Steuer '
'mit dem Tarif neu (§ 67 Abs 10 Günstig-Aufrollung). '
'SV rollt auf den Beitragsmonat (GP4); mBGM-Storno '
'(GP7) wenn der Beitragsmonat wechselt.'),
}
return None
def _zusammenfassung(self):
self.ensure_one()
ohne = self.ergebnis_ids.filtered(lambda r: r.guenstig)
mit = self.ergebnis_ids.filtered(lambda r: not r.guenstig)
return (
'Kontrollsechstel-Prüfung %d: %d Mitarbeiter mit '
'Überhang, davon %d mit Günstig-Aufrollung (= Tarif '
'günstiger als Fixsätze → Korrekturlauf).\n'
'§-77-Prüfung: %s.'
% (self.jahr, len(self.ergebnis_ids), len(mit),
'aktiviert' if self.aufrollung_77 else 'deaktiviert'))
class L10nAtPayrollJahresretroErgebnis(models.TransientModel):
_name = 'l10n.at.payroll.jahresretro.ergebnis'
_description = 'Jahres-Retro — Prüfergebnis je AN'
wizard_id = fields.Many2one(
'l10n.at.payroll.jahresretro.wizard', required=True,
ondelete='cascade')
employee_id = fields.Many2one('hr.employee', required=True)
kontrollsechstel_ueberhang = fields.Monetary(
currency_field='currency_id')
fixsatz_steuer = fields.Monetary(
currency_field='currency_id',
string='SZ-Steuer (Fixsätze)')
tarif_steuer = fields.Monetary(
currency_field='currency_id',
string='SZ-Steuer (Tarif)')
guenstig = fields.Boolean(
string='Fixsätze günstiger (keine Korrektur)')
handlung = fields.Text(string='Handlungsempfehlung')
currency_id = fields.Many2one(
'res.currency', related='wizard_id.company_id.currency_id')
@@ -0,0 +1,97 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""ELDA-Mitteilungsfile-Import (GP8): Rückantwort der Clearing-
stelle/Dateninhaltsprüfung als XML (`mitteilung_{Protokollnummer}.xml`).
XSDs lokal beschafft (`.firecrawl/ris/elda/Mitteilungsfiles_ZIP.zip`
— elda_mitteilung-1.2/2.1/3.0.xsd, nicht versioniert).
Der Import parst das XML, reichert die GP7-Deklarationsmodelle
(L16/mBGM) mit dem offiziellen Prüfergebnis an und liefert ein
lesbares Protokoll. Dateibasiert (Prämisse P7): der Mandant lädt
das Mitteilungsfile aus dem ELDA-Portal und lädt es hier hoch."""
import base64
import logging
import re
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class L10nAtPayrollMitteilungsfileWizard(models.TransientModel):
_name = 'l10n.at.payroll.mitteilungsfile.wizard'
_description = 'ELDA-Mitteilungsfile importieren (GP8)'
company_id = fields.Many2one(
'res.company', required=True,
default=lambda self: self.env.company)
datei = fields.Binary(
required=True, string='Mitteilungsfile (XML)',
help='ELDA-Rückantwort: mitteilung_{Protokollnummer}.xml '
'aus dem Empfangsverzeichnis des ELDA-Portals.')
datei_name = fields.Char()
protokoll = fields.Text(readonly=True)
def action_importieren(self):
"""XML parsen und Protokollzeilen extrahieren; die GP7-
Deklarationsmodelle werden über REFP/REFW-Matches verlinkt,
wo möglich. Das Mitteilungsfile 3.0 hat die Struktur:
<mitteilung> → <sendung> → <fehler>/<warnung> (je Satz-
art/REFW-Bezug)."""
for wizard in self:
if not wizard.datei:
raise UserError('Keine Datei hochgeladen.')
roh = base64.b64decode(wizard.datei)
try:
inhalt = roh.decode('utf-8')
except UnicodeDecodeError:
inhalt = roh.decode('iso-8859-15', errors='replace')
zeilen = wizard._parse_mitteilungsfile(inhalt)
wizard.protokoll = '\n'.join(zeilen) or 'Keine Fehler ' \
'oder Warnungen gefunden.'
return True
@api.model
def _parse_mitteilungsfile(self, inhalt):
"""Einfacher XML-Textparser: extrahiert <fehler>/<warnung>
Elemente mit Attributen und Textinhalt. Kein voller XSD-
Validator — die Fachprüfung bleibt der Clearingstelle; hier
wird das Ergebnis lesbar aufbereitet (Prämisse P7)."""
zeilen = []
# <fehler code="F0100" satzart="L1" refw="..."
# beschreibung="..."/> — XSD-abhängige Struktur.
for match in re.finditer(
r'<(?:fehler|warnung|error|warning)\b([^>]*?)/?>',
inhalt, re.IGNORECASE):
attrs = match.group(1)
art = 'Fehler' if 'fehler' in match.group(0).lower() \
else 'Warnung'
code = re.search(r'code="([^"]*)"', attrs)
satzart = re.search(r'satzart="([^"]*)"', attrs)
beschreibung = re.search(
r'(?:beschreibung|description|text)="([^"]*)"', attrs)
refw = re.search(
r'(?:refw|refn|referenz)="([^"]*)"', attrs)
zeile = art
if code:
zeile += ' %s' % code.group(1)
if satzart:
zeile += ' (Satzart %s)' % satzart.group(1)
if refw:
zeile += ' [Ref: %s]' % refw.group(1)
if beschreibung:
zeile += ': %s' % beschreibung.group(1)
if not code and not beschreibung:
zeile += ': %s' % match.group(0)[:120]
zeilen.append(zeile)
# Kopfprotokoll (Sendungsstatus).
for match in re.finditer(
r'<sendung\b([^>]*?)/?>', inhalt, re.IGNORECASE):
attrs = match.group(1)
status = re.search(r'status="([^"]*)"', attrs)
if status:
zeilen.insert(0, 'Sendungsstatus: %s' % status.group(1))
if not zeilen:
zeilen.append('Mitteilungsfile gelesen — keine '
'<fehler>/<warnung>-Elemente gefunden.')
return zeilen
@@ -7,4 +7,9 @@ access_l10n_at_payroll_l16_user,L10n AT Payroll L16 User,model_l10n_at_payroll_l
access_l10n_at_payroll_l16_manager,L10n AT Payroll L16 Manager,model_l10n_at_payroll_l16,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_l16_line_user,L10n AT Payroll L16-Line User,model_l10n_at_payroll_l16_line,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_kind_user,L10n AT Payroll Kind User,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_kind_manager,L10n AT Payroll Kind Manager,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_kind_manager,L10n AT Payroll Kind Manager,model_l10n_at_payroll_kind,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_frist_user,L10n AT Payroll Frist User,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_frist_manager,L10n AT Payroll Frist Manager,model_l10n_at_payroll_frist,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_jahresretro_wizard,L10n AT Payroll Jahresretro Wizard,model_l10n_at_payroll_jahresretro_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_jahresretro_ergebnis,L10n AT Payroll Jahresretro Ergebnis,model_l10n_at_payroll_jahresretro_ergebnis,hr_payroll.group_hr_payroll_user,1,1,1,1
access_l10n_at_payroll_mitteilungsfile_wizard,L10n AT Payroll Mitteilungsfile Wizard,model_l10n_at_payroll_mitteilungsfile_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_l10n_at_payroll_l16_manager L10n AT Payroll L16 Manager model_l10n_at_payroll_l16 hr_payroll.group_hr_payroll_manager 1 1 1 1
8 access_l10n_at_payroll_l16_line_user L10n AT Payroll L16-Line User model_l10n_at_payroll_l16_line hr_payroll.group_hr_payroll_user 1 1 1 1
9 access_l10n_at_payroll_kind_user L10n AT Payroll Kind User model_l10n_at_payroll_kind hr_payroll.group_hr_payroll_user 1 1 1 1
10 access_l10n_at_payroll_kind_manager L10n AT Payroll Kind Manager model_l10n_at_payroll_kind hr_payroll.group_hr_payroll_manager 1 1 1 1
11 access_l10n_at_payroll_frist_user L10n AT Payroll Frist User model_l10n_at_payroll_frist hr_payroll.group_hr_payroll_user 1 1 1 1
12 access_l10n_at_payroll_frist_manager L10n AT Payroll Frist Manager model_l10n_at_payroll_frist hr_payroll.group_hr_payroll_manager 1 1 1 1
13 access_l10n_at_payroll_jahresretro_wizard L10n AT Payroll Jahresretro Wizard model_l10n_at_payroll_jahresretro_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
14 access_l10n_at_payroll_jahresretro_ergebnis L10n AT Payroll Jahresretro Ergebnis model_l10n_at_payroll_jahresretro_ergebnis hr_payroll.group_hr_payroll_user 1 1 1 1
15 access_l10n_at_payroll_mitteilungsfile_wizard L10n AT Payroll Mitteilungsfile Wizard model_l10n_at_payroll_mitteilungsfile_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
+2 -1
View File
@@ -2,4 +2,5 @@
from . import test_tasy_import
from . import test_lohnsteuer
from . import test_sozialversicherung
from . import test_elda_l16
from . import test_elda_l16
from . import test_gp8
+107
View File
@@ -0,0 +1,107 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP8 Kern: Fristenkalender (Auto-Erzeugung, Frist-Typen, Überfällig-
Logik), Jahres-Retro (Kontrollsechstel-Prüfung), Mitteilungsfile-Parser."""
from datetime import date
from odoo.tests import TransactionCase, tagged
from odoo.addons.l10n_at_hr_payroll.models.fristenkalender import (
FRISTART_DEFINITIONEN,
)
@tagged('post_install', '-at_install')
class FristenTest(TransactionCase):
def test_01_frist_definitionen(self):
"""Alle Fristarten sind definiert mit Name/Deadline-Logik."""
for art, definition in FRISTART_DEFINITIONEN.items():
self.assertIn('name', definition)
self.assertIn('hilfetext', definition)
self.assertTrue(
'monatlich' in definition or 'monat' in definition,
'Fristart %s ohne Fälligkeits-Logik' % art)
def test_02_cron_erzeugt_fristen(self):
"""Cron erzeugt monatliche SV/DB-Fristen idempotent + jährliche
Fristen am 1. Jänner."""
frist = self.env['l10n.at.payroll.frist']
company = self.env.company
# Manueller Cron-Lauf (heute ist ein Tag im Jahr).
frist._cron_fristen_erzeugen()
# Monatliche Fristen existieren für den Vormonat.
heute = date.today()
vormonat = (heute.replace(day=1) - __import__('datetime')
.timedelta(days=1))
monatliche = frist.search([
('company_id', '=', company.id),
('fristart', 'in', ('sv_mbgm', 'db_dz_kommst')),
('jahr', '=', vormonat.year),
('monat', '=', vormonat.month)])
self.assertEqual(len(monatliche), 2)
# Idempotenz: zweiter Lauf erzeugt keine Duplikate.
frist._cron_fristen_erzeugen()
monatliche2 = frist.search([
('company_id', '=', company.id),
('fristart', 'in', ('sv_mbgm', 'db_dz_kommst')),
('jahr', '=', vormonat.year),
('monat', '=', vormonat.month)])
self.assertEqual(len(monatliche2), 2)
def test_03_state_ueberfaellig(self):
"""Überfällige Fristen markieren sich nach Deadline-
Überschreitung; action_erledigt setzt erledigt."""
frist = self.env['l10n.at.payroll.frist'].create({
'company_id': self.env.company.id,
'fristart': 'l16',
'jahr': 2025,
'monat': 0,
'deadline': '2026-01-01',
})
self.assertEqual(frist.state, 'ueberfaellig')
frist.action_erledigt()
self.assertEqual(frist.state, 'erledigt')
self.assertTrue(frist.erledigt_am)
@tagged('post_install', '-at_install')
class MitteilungsfileTest(TransactionCase):
def test_01_parse_mitteilungsfile(self):
"""XML-Parser extrahiert Fehler-/Warnung-Elemente."""
wizard = self.env['l10n.at.payroll.mitteilungsfile.wizard']
xml = """<?xml version="1.0"?>
<mitteilung>
<sendung status="ok"/>
<fehler code="F0100" satzart="L1"
beschreibung="STEUERNUMMER fehlt"/>
<warnung code="W0102" satzart="G1"
beschreibung="Beitragssatz-Differenz"/>
</mitteilung>"""
zeilen = wizard._parse_mitteilungsfile(xml)
self.assertIn('Sendungsstatus: ok', zeilen)
self.assertTrue(any('F0100' in z for z in zeilen))
self.assertTrue(any('W0102' in z for z in zeilen))
self.assertTrue(any('STEUERNUMMER' in z for z in zeilen))
def test_02_import_ohne_datei(self):
"""Import ohne Datei löst UserError."""
from odoo.exceptions import UserError
wizard = self.env['l10n.at.payroll.mitteilungsfile.wizard'] \
.create({'company_id': self.env.company.id})
with self.assertRaises(UserError):
wizard.action_importieren()
@tagged('post_install', '-at_install')
class JahresretroTest(TransactionCase):
def test_01_wizard_ohne_daten(self):
"""Wizard ohne Läufe im Jahr: leeres Ergebnis, keine Fehler."""
wizard = self.env['l10n.at.payroll.jahresretro.wizard'].create({
'company_id': self.env.company.id,
'jahr': 1999, # weit in der Vergangenheit
})
wizard.action_pruefen()
self.assertFalse(wizard.ergebnis_ids)
self.assertIn('1999', wizard.zusammenfassung)
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP8: Fristenkalender + Jahres-Retro + Mitteilungsfile-Import. -->
<!-- ============================================================ -->
<record id="frist_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.frist.list</field>
<field name="model">l10n.at.payroll.frist</field>
<field name="arch" type="xml">
<list string="Fristenkalender" default_order="deadline">
<field name="deadline"/>
<field name="fristart"/>
<field name="jahr"/>
<field name="monat" optional="hide"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="state" widget="badge"
decoration-success="state == 'erledigt'"
decoration-warning="state == 'offen'"
decoration-danger="state == 'ueberfaellig'"/>
<button name="action_erledigt" type="object"
icon="fa-check" string="Erledigt"
invisible="state == 'erledigt'"/>
<button name="action_zum_objekt" type="object"
icon="fa-arrow-right" string="Zur Meldung"/>
</list>
</field>
</record>
<record id="frist_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.frist.form</field>
<field name="model">l10n.at.payroll.frist</field>
<field name="arch" type="xml">
<form string="Frist">
<sheet>
<group>
<group>
<field name="fristart"/>
<field name="jahr"/>
<field name="monat"/>
<field name="deadline"/>
</group>
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="state" widget="badge"/>
<field name="erledigt_am"/>
</group>
</group>
<notebook>
<page string="Hinweis" name="hinweis">
<field name="hilfetext"/>
</page>
<page string="Meldungsobjekt" name="objekt">
<group>
<field name="ref_l16_id"/>
</group>
</page>
</notebook>
</sheet>
<header>
<button name="action_erledigt" type="object"
string="Als erledigt markieren"
invisible="state == 'erledigt'"/>
<button name="action_wieder_eroeffnen" type="object"
string="Wieder eröffnen"
invisible="state != 'erledigt'"/>
</header>
</form>
</field>
</record>
<record id="action_frist" model="ir.actions.act_window">
<field name="name">Fristenkalender</field>
<field name="res_model">l10n.at.payroll.frist</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_state': 'offen'}</field>
</record>
<!-- Jahres-Retro-Wizard -->
<record id="jahresretro_wizard_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.jahresretro.wizard.form</field>
<field name="model">l10n.at.payroll.jahresretro.wizard</field>
<field name="arch" type="xml">
<form string="Jahres-Retro-/Aufrollungslauf">
<group>
<group>
<field name="jahr"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
<group>
<field name="nur_kontrollsechstel"/>
<field name="aufrollung_77"/>
<field name="zeige_detail"/>
</group>
</group>
<footer>
<button name="action_pruefen" type="object"
string="Jahresdaten prüfen" class="btn-primary"/>
<button string="Schließen" class="btn-secondary"
special="cancel"/>
</footer>
<notebook invisible="not zeige_detail">
<page string="Prüfergebnis" name="ergebnis">
<field name="ergebnis_ids">
<list create="0" delete="0">
<field name="employee_id"/>
<field name="kontrollsechstel_ueberhang"/>
<field name="fixsatz_steuer"/>
<field name="tarif_steuer"/>
<field name="guenstig" widget="badge"
decoration-success="guenstig"
decoration-danger="not guenstig"/>
<field name="handlung"/>
</list>
</field>
</page>
<page string="Zusammenfassung" name="zusammenfassung">
<field name="zusammenfassung"/>
</page>
</notebook>
</form>
</field>
</record>
<record id="action_jahresretro_wizard" model="ir.actions.act_window">
<field name="name">Jahres-Retro-/Aufrollungslauf</field>
<field name="res_model">l10n.at.payroll.jahresretro.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Mitteilungsfile-Import -->
<record id="mitteilungsfile_wizard_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.mitteilungsfile.wizard.form</field>
<field name="model">l10n.at.payroll.mitteilungsfile.wizard</field>
<field name="arch" type="xml">
<form string="ELDA-Mitteilungsfile importieren">
<group>
<field name="company_id"
groups="base.group_multi_company"/>
<field name="datei" filename="datei_name"/>
<field name="datei_name" invisible="1"/>
</group>
<footer>
<button name="action_importieren" type="object"
string="Importieren" class="btn-primary"/>
<button string="Schließen" class="btn-secondary"
special="cancel"/>
</footer>
<field name="protokoll" invisible="not protokoll"/>
</form>
</field>
</record>
<record id="action_mitteilungsfile_wizard" model="ir.actions.act_window">
<field name="name">ELDA-Mitteilungsfile importieren</field>
<field name="res_model">l10n.at.payroll.mitteilungsfile.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<!-- Cron: Fristen monatlich/jährlich erzeugen -->
<record id="ir_cron_fristen_erzeugen" model="ir.cron">
<field name="name">L10n AT Payroll: Fristenkalender pflegen</field>
<field name="model_id" ref="model_l10n_at_payroll_frist"/>
<field name="state">code</field>
<field name="code">model._cron_fristen_erzeugen()</field>
<field name="interval_number">1</field>
<field name="interval_type">months</field>
<field name="nextcall">2026-10-01 02:00:00</field>
<field name="active" eval="True"/>
</record>
<!-- Menüs -->
<menuitem id="menu_l10n_at_payroll_fristen"
name="Fristenkalender"
parent="menu_l10n_at_payroll_meldewesen"
action="action_frist" sequence="5"/>
<menuitem id="menu_l10n_at_payroll_jahresretro"
name="Jahres-Retro-/Aufrollungslauf"
parent="menu_l10n_at_payroll_meldewesen"
action="action_jahresretro_wizard" sequence="20"/>
<menuitem id="menu_l10n_at_payroll_mitteilungsfile"
name="Mitteilungsfile-Import"
parent="menu_l10n_at_payroll_meldewesen"
action="action_mitteilungsfile_wizard" sequence="30"/>
</odoo>