diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py
index c8e5df2..bae0865 100644
--- a/addons/l10n_at_hr_payroll/__manifest__.py
+++ b/addons/l10n_at_hr_payroll/__manifest__.py
@@ -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'],
diff --git a/addons/l10n_at_hr_payroll/data/rule_parameters_2027.xml b/addons/l10n_at_hr_payroll/data/rule_parameters_2027.xml
new file mode 100644
index 0000000..b3599ff
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/data/rule_parameters_2027.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+ 2027-01-01
+ {'default': {'dn': 0.5, 'dg': 0.5}}
+
+
+
+
+
+
+
+ 2027-01-01
+ {'tiers': [], 'dn_voll': 2.95}
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/models/__init__.py b/addons/l10n_at_hr_payroll/models/__init__.py
index 41bb2b6..02a6466 100644
--- a/addons/l10n_at_hr_payroll/models/__init__.py
+++ b/addons/l10n_at_hr_payroll/models/__init__.py
@@ -5,4 +5,7 @@ from . import sozialversicherung
from . import elda
from . import meldewesen
from . import kind
-from . import lohnzettel_l16
\ No newline at end of file
+from . import lohnzettel_l16
+from . import fristenkalender
+from . import jahresretro
+from . import mitteilungsfile
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/models/fristenkalender.py b/addons/l10n_at_hr_payroll/models/fristenkalender.py
new file mode 100644
index 0000000..0c81f97
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/models/fristenkalender.py
@@ -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='1–12; 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']
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/models/jahresretro.py b/addons/l10n_at_hr_payroll/models/jahresretro.py
new file mode 100644
index 0000000..9ee43ec
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/models/jahresretro.py
@@ -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')
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/models/mitteilungsfile.py b/addons/l10n_at_hr_payroll/models/mitteilungsfile.py
new file mode 100644
index 0000000..75a438a
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/models/mitteilungsfile.py
@@ -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:
+ → → / (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 /
+ 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 = []
+ # — 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']*?)/?>', 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 '
+ '/-Elemente gefunden.')
+ return zeilen
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/security/ir.model.access.csv b/addons/l10n_at_hr_payroll/security/ir.model.access.csv
index 482a992..8650627 100644
--- a/addons/l10n_at_hr_payroll/security/ir.model.access.csv
+++ b/addons/l10n_at_hr_payroll/security/ir.model.access.csv
@@ -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
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/tests/__init__.py b/addons/l10n_at_hr_payroll/tests/__init__.py
index f690b87..a06b028 100644
--- a/addons/l10n_at_hr_payroll/tests/__init__.py
+++ b/addons/l10n_at_hr_payroll/tests/__init__.py
@@ -2,4 +2,5 @@
from . import test_tasy_import
from . import test_lohnsteuer
from . import test_sozialversicherung
-from . import test_elda_l16
\ No newline at end of file
+from . import test_elda_l16
+from . import test_gp8
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/tests/test_gp8.py b/addons/l10n_at_hr_payroll/tests/test_gp8.py
new file mode 100644
index 0000000..db2c0bf
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/tests/test_gp8.py
@@ -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 = """
+
+
+
+
+ """
+ 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)
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/views/fristen_views.xml b/addons/l10n_at_hr_payroll/views/fristen_views.xml
new file mode 100644
index 0000000..f0439e2
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/views/fristen_views.xml
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+ l10n.at.payroll.frist.list
+ l10n.at.payroll.frist
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ l10n.at.payroll.frist.form
+ l10n.at.payroll.frist
+
+
+
+
+
+
+ Fristenkalender
+ l10n.at.payroll.frist
+ list,form
+ {'search_default_state': 'offen'}
+
+
+
+
+ l10n.at.payroll.jahresretro.wizard.form
+ l10n.at.payroll.jahresretro.wizard
+
+
+
+
+
+
+ Jahres-Retro-/Aufrollungslauf
+ l10n.at.payroll.jahresretro.wizard
+ form
+ new
+
+
+
+
+ l10n.at.payroll.mitteilungsfile.wizard.form
+ l10n.at.payroll.mitteilungsfile.wizard
+
+
+
+
+
+
+ ELDA-Mitteilungsfile importieren
+ l10n.at.payroll.mitteilungsfile.wizard
+ form
+ new
+
+
+
+
+ L10n AT Payroll: Fristenkalender pflegen
+
+ code
+ model._cron_fristen_erzeugen()
+ 1
+ months
+ 2026-10-01 02:00:00
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file