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 + + + + + + + + +