diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 7ac529e..f4317c8 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,6 +2,77 @@ ## Current focus +**Modul l10n_at_payroll_dokumente (Dokumentgenerierung) umgesetzt — 236/236 Tests grün** +(2026-09-16, uncommittet bis Nutzerfreigabe; Modul **19.0.1.0.0**): + +- **Anforderung (Nutzer):** Muster → Dokumente aus Stammdaten (Dienstvertrag, + Kündigung, DSE …). Nutzer-Entscheidungen: neues Modul; DSE = vorhandene + Pfändungs-DSE (kein Neubau); **eigene Texte nach Muster-Struktur** (kb_ref/ + quell_stand je Vorlage dokumentiert, kein Lexis-Volltext im Repo — + Lizenzthema damit fürs Produkt gelöst; Muster werden laufend ergänzt, + kein Warten nötig: neue Vorlagen = Datenarbeit). +- **Bau:** Modell `l10n.at.payroll.dokument.vorlage` (+ feld): eigener Text + `inhalt_html` (Html, sanitize=False), Feldliste (auto/manuell, pflicht), + kb_ref/quell_stand/rechtsstatus (entwurf|geprueft), company + Rule. + Wizard `l10n.at.payroll.dokument.wizard` (+ Zeilen-Transient): Stichtag → + hr.version (date_version <= Stichtag!), Auto-Resolver (firma/firma_strasse/ + firma_plz/firma_ort/mitarbeiter/mitarbeiter_strasse/plz/ort/ead/funktion/ + geboren), Manuell-Eingaben, Pflicht-UserError, Vorschau, PDF via + `ir.actions.report._render_qweb_pdf` **mit HTML-Fallback** ohne + wkhtmltopdf (Anhang am hr.employee). Generisches QWeb-Print-Layout + (AbstractModel + data['body'] als Markup, wie DSE-Muster). +- **⚠️ Rendering-Weg (wichtig, abweichend vom Plan):** NICHT + `mail.render.mixin._render_template` (engine inline_template) — deren + Ausdrucks-Whitelist (`mail_allowed_qweb_expressions`, nur object.name u.ä.) + sperrt `werte['…']`-Ausdrücke für Nicht-Template-Editoren (AccessError). + Stattdessen: `rendering_tools.convert_inline_template_to_qweb` → + `ir.qweb._render(lxml html.fragment_fromstring(src, create_parent='div'), …)` + + Wrapper-[5:-6]-Strip (Muster `_render_template_qweb`). Autor-Syntax + bleibt identisch; **t-out escapt Werte automatisch** (kein manuelles + Escaping). t-if-Blöcke für optionale Absätze sind möglich (QWeb direkt). +- **Pilotvorlagen (eigen formuliert, Rechtsstatus 'entwurf'):** DV + Angestellte (lb-bes-09), Kündigung DG (lb-bnd-55), einvernehmliche + Auflösung (lb-bnd-52/62), Vordienstzeitenbestätigung (lb-end-35). + §-Verifikation: § 27 AngG (wichtiger Grund) RIS ✅; **§ 34 AngG = + Verjährung** (nicht Auflösung!) — Auflösungstexte zitieren bewusst keine §§. +- **Tests 8** (Auto-Werte, Platzhalter-Ersetzung, Pflicht-UserError, + Stichtags-Versionen, Anhang PDF/HTML, generisches Layout via + `_render_qweb_html` ohne wkhtmltopdf, ACL hr.group_hr_user vs. base, + Pilotvorlagen E2E). Suite **236/236** (73 Kern + 18 GemBG + 199 Privat + + 10 Dokumente) frischer -i-Lauf. +- Offen: DSE-Pfändung-Link ins Dokumente-Menü (kosmetisch), mehr Vorlagen + nach Nutzermustererweiterung, Produktivsetzung erst nach rechtlicher + Prüfung (rechtsstatus je Vorlage). + +**Erneuter Abgleich Modul ↔ Wissensbasis (2026-09-16, Read-only, Grep-Verifikation am Code):** +Basis PLAN-Restluecken-AP11 (Abgleich 2026-09-12, 601 Einträge) + Zwischenstand AP8/AP11–AP16. +Befund: +- **Geplante Reste unverändert offen (0 Code-Treffer je Thema):** AP17 (L17, § 109a/ + § 109b-Meldung, mBGM Geringfügigen-Jahreszahlung 2,5 %), AP18 (NSchG-Monatszähler, + Schwerarbeitsmeldung — ASchV/NSchG-Beschaffungsgate), AP19 (GF/Vorstand-Flag, + Ferialpraktikanten, DVH-Sonderfälle, Präsenzdienst-BV-Fiktion), AP20+-Optionen + (ATZ, Kurzarbeit, Betriebspension/Beteiligung, Entsendung), Quer-LSt: FB+/ANV- + Automatik, Prämien-Anspruchsprinzip (A3-blockiert), 2027-Werte. +- **Zwischenzeitlich ✅ (Code-verifiziert):** All-in § 2g AVRAG (GP2-Deckungsrechnung, + payslip_private `_at_allin_deckung`), Fallweise-Felder + mBGM G3/G4 tagesgenau, + AVAB/AVB-Selection, Nachzahlungspfade (SV/LSt/Endabrechnung), Tod des DN = + Abgangsursache 13, §§ 23f/§ 47-Übertritte in abfertigung_private. +- **1 NEUER Restpunkt aus Batch 10:** **Ausbildungskostenrückersatz** (lb-end-27/28, + Checkliste + Muster) — 0 Code-Treffer, auch nicht im AP11-Plan erfasst → + als Zusatzpunkt in AP14/Endabrechnung (Scope-Gate Pilotmandant) aufnehmen. +- Batch 10 sonst: keine neuen Cluster, keine neuen Berechnungslogiken (Muster/ + Checklisten/Übersichten); lb-end-30/31 (freiwillige Abf./Voll-Übertritt) und + lb-end-29 (Zeitschulden) abgedeckt; lb-grz-09..11/lb-swa-06 stützen AP18/AP20. +- KB-interne offene Punkte unverändert: A3 „Formel 7“, A4 All-In-Oberwert, + A7-2026-GFG, C7-2027-Werte, B3 Pensionisten (VwGH), ASchV/NSchG-Beschaffung. +- **Nachgetragen 2026-09-16 (Nutzer):** Ausbildungskostenrückersatz in + `PLAN-Restluecken-AP11.md` (AP14-Umsetzung/Rechtsanker + Lücken-Liste 2.4). +- **Neue Anfrage (2026-09-16, ERLEDIGT — siehe Modul-Block oben):** + `.lexis360/muster/` für Dokumente in Odoo nutzen → Modul + `l10n_at_payroll_dokumente` umgesetzt; Nutzer entschieden: eigene Texte + nach Muster-Struktur (kein Lexis-Volltext im Repo), Lizenz-Redistribution + damit fürs Produkt gelöst; Sammlung wird laufend ergänzt (kein Warten). + **Wissensbasis Batch 10 (3 neue Lexis-Subordner) ABGESCHLOSSEN — 64/64 kuratiert, `--registry` + `--check` grün** (2026-09-16, Nutzerfreigabe "bitte zur wissensbasis hinzufuegen"): diff --git a/README.md b/README.md index bd3ff3f..9da195c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ seit 2026-09-09 — Überführung aus `gem360-git` per History-Import; siehe |---|---| | `l10n_at_hr_payroll` | **Bundes-Kern** (sektor-neutral): SV-Werte 2026 als `hr.rule.parameter`-Seed, TASY-Import-Wizard; folgen: Lohnsteuer (AP3), Meldewesen L16/eSV (AP5), Belege (AP6) | | `l10n_at_gemeinde_payroll` | **Bgld. GemBG 2014**: Katalogmodell (AP1), Entgelt-Engine (AP2); AP3+ gemäß `personalverrechnung/IMPLEMENTIERUNGSPLAN-Bgld.md` | +| `l10n_at_payroll_dokumente` | **Dokumente aus Vorlagen**: Dokumentgenerierung (Dienstvertrag, Kündigung, Bescheinigungen …) aus hr-Stammdaten; eigene Texte, Struktur-Referenz (kb_ref) dokumentiert; Wizard mit Auto-/Manuellfeldern, Vorschau, PDF/HTML-Ablage | | `l10n_at_hr_payroll_private` | **General-AT** (KV-basierte Privatwirtschaft) — Gerüst; Implementierungsplan freigegeben ([`IMPLEMENTIERUNGSPLAN-Privat.md`](personalverrechnung/IMPLEMENTIERUNGSPLAN-Privat.md)), Umsetzung ab M1 (GP0) | VRV-Buchung für Gemeinden (Anlage 3b, Ansatz/MVAG, EHH/FHH) läuft über diff --git a/addons/l10n_at_payroll_dokumente/__init__.py b/addons/l10n_at_payroll_dokumente/__init__.py new file mode 100644 index 0000000..ecc9fab --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/__init__.py @@ -0,0 +1,3 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import models +from . import tests \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/__manifest__.py b/addons/l10n_at_payroll_dokumente/__manifest__.py new file mode 100644 index 0000000..169d566 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/__manifest__.py @@ -0,0 +1,33 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +{ + 'name': 'AT Payroll: Dokumente aus Vorlagen', + 'version': '19.0.1.0.0', + 'category': 'Human Resources/Payroll', + 'summary': 'Dokumentgenerierung aus Vorlagen mit hr-Stammdaten (eigene Texte)', + 'description': """ +Dokumente (Dienstvertrag, Kündigung, Bescheinigungen, ...) aus Vorlagen, +befüllt aus hr-Stammdaten und Wizard-Eingaben. + +- Vorlagen enthalten ausschließlich eigenen Text; Struktur-Referenz und + Quell-Stand sind pro Vorlage dokumentiert (kb_ref/quell_stand). +- Platzhalter-Syntax des Standard-Inline-Renderers von mail.render.mixin: + {{ werte['key'] }}, {{ object.feld }}, {{ format_date(...) }}. +- PDF-Erzeugung über ir.actions.report (wkhtmltopdf); ohne PDF-Engine + wird das gerenderte HTML abgelegt. +""", + 'author': 'odoo-at-payroll', + 'website': 'https://example.com/odoo-at-payroll', + 'license': 'LGPL-3', + 'depends': ['hr', 'mail'], + 'data': [ + 'security/ir.model.access.csv', + 'security/security_rules.xml', + 'data/vorlagen_pilot.xml', + 'views/dokument_vorlage_views.xml', + 'views/wizard_views.xml', + 'report/report_dokument.xml', + ], + 'demo': [], + 'installable': True, + 'application': False, +} \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/data/vorlagen_pilot.xml b/addons/l10n_at_payroll_dokumente/data/vorlagen_pilot.xml new file mode 100644 index 0000000..719c4b6 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/data/vorlagen_pilot.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + Dienstvertrag (Angestellte) + dienstvertrag + lb-bes-09 + 2025-06 + entwurf + +Rahmen-Dienstvertrag für Angestellte (privatrechtlich). Struktur nach dem +Lexis-Muster lb-bes-09 (Stand 2025-06), Text eigen formuliert. +Anzupassen je Mandant/KV: Einstufung, Entgelt, Überstundenregelung, +Kündigungsfristen; optionale Blöcke (Geheimhaltung, Konkurrenzklausel) +erscheinen nur, wenn befüllt. Vertragswerte (Fristen, Tage) sind +Platzhalter — keine Gesetz/KV-Werte im Template verankert. + + +

{{ werte['firma'] }}
{{ werte['firma_strasse'] }}
{{ werte['firma_plz'] }} {{ werte['firma_ort'] }}

+

Dienstvertrag

+

zwischen {{ werte['firma'] }}, {{ werte['firma_strasse'] }}, {{ werte['firma_plz'] }} {{ werte['firma_ort'] }} — im Folgenden „der Dienstgeber“ — und {{ werte['mitarbeiter'] }}, {{ werte['mitarbeiter_strasse'] }}, {{ werte['mitarbeiter_plz'] }} {{ werte['mitarbeiter_ort'] }} — im Folgenden „der Dienstnehmer“.

+

I. Tätigkeitsbereich

+

1. Der Dienstnehmer wird ab {{ werte['ead'] }} als {{ werte['funktion'] }} angestellt. Übertragen werden ihm insbesondere folgende Tätigkeiten:
{{ werte['taetigkeit'] }}

+

2. Der Dienstnehmer hat die übertragenen Tätigkeiten mit der Sorgfalt eines ordentlichen Dienstnehmers auszuüben; im Bedarfsfall sind auch verwandte Tätigkeiten nach Weisung des Dienstgebers zu verrichten.

+

II. Dienstort

+

Dienstort ist {{ werte['dienstort'] }}. Der Dienstnehmer verpflichtet sich, im Interesse des Dienstgebers auch außerhalb des Dienstortes vorübergehend tätig zu werden, wenn dies die Tätigkeit erfordert.

+

III. Einstufung

+

Die Einstufung erfolgt gemäß den anwendbaren kollektivvertraglichen Bestimmungen: {{ werte['einstufung'] }}.

+

IV. Entgelt

+

1. Für die geleisteten Dienste gebührt dem Dienstnehmer ein Monatsentgelt von EUR {{ werte['entgelt'] }} brutto, zahlbar monatlich nach Maßgabe der kollektivvertraglichen Bestimmungen ({{ werte['kv_verweis'] }}).

+

2. Sonderzahlungen gebühren gemäß den anwendbaren kollektivvertraglichen Bestimmungen; die Abrechnung erfolgt nach den jeweils geltenden lohnsteuerlichen Vorgaben.

+

V. Arbeitszeit, Mehr- und Überstunden

+

1. Die regelmäßige Dienstzeit beträgt {{ werte['arbeitszeit'] }} je Woche, verteilt auf fünf Arbeitstage von Montag bis Freitag. Die Lage der Tagesarbeitszeit wird mit dem Dienstgeber vereinbart.

+

2. Mehr- und Überstunden sind nach den Bestimmungen des Arbeitszeitgesetzes zu leisten; der Abgeltung liegen die anwendbaren kollektivvertraglichen Bestimmungen zugrunde.

+

VI. Spesen

+

Spesen im Zusammenhang mit der Dienstausübung werden nach den geltenden Regelungen des Dienstgebers abgerechnet.

+

VII. Urlaub

+

Der Dienstnehmer hat Anspruch auf bezahlten Urlaub im Ausmaß von {{ werte['urlaub_tage'] }} Arbeitstagen je Dienstjahr; im Übrigen gelten die Bestimmungen des Urlaubs- und Abfertigungsgesetzes.

+

VIII. Entgeltfortzahlung

+

Bei Dienstverhinderung gelten die gesetzlichen Bestimmungen über die Entgeltfortzahlung.

+

IX. Dauer

+

1. Das Dienstverhältnis beginnt am {{ werte['ead'] }} und wird auf unbestimmte Zeit geschlossen.

+

2. Das Dienstverhältnis kann von beiden Vertragsparteien unter Einhaltung der gesetzlichen bzw. kollektivvertraglich vereinbarten Kündigungsfristen ordentlich gekündigt werden; geltend ist die Frist {{ werte['kuendigung_frist'] }}.

+
+

X. Geheimhaltung

+

{{ werte['geheimhaltung'] }}

+
+
+

XI. Nebentätigkeit/Konkurrenzklausel

+

{{ werte['konkurrenzklausel'] }}

+
+

XII. Schlussbestimmungen

+

1. Abfertigungsansprüche richten sich nach den Bestimmungen des BMSVG.

+

2. Nebenabreden und Änderungen dieses Vertrages bedürfen der Schriftform; mündliche Nebenabreden bestehen nicht.

+

3. Sollte eine Bestimmung dieses Vertrages unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt; an deren Stelle tritt eine wirksame Bestimmung mit gleichem wirtschaftlichem Gehalt.

+

Ort, Datum: {{ werte['ort_datum'] }}

Dienstgeber: ________________________

Dienstnehmer: ______________________

+ + ]]>
+ +
+ + + + Kündigung durch den Dienstgeber + beendigung + lb-bnd-55 + 2026-07 + entwurf + +Ordentliche Kündigung durch den Dienstgeber (Rahmen). Struktur nach dem +Lexis-Muster lb-bnd-55 (Stand 2026-07). Betriebsrat-Verweis und +Resturlaubs-/Freistellungsblöcke sind optional (leer = entfällt); +Personalvertretungsfälle (Gemeinden) sind gesondert zu ergänzen. + + +

{{ werte['firma'] }}
{{ werte['firma_strasse'] }}
{{ werte['firma_plz'] }} {{ werte['firma_ort'] }}

+

An {{ werte['anrede'] }} {{ werte['mitarbeiter'] }}
{{ werte['mitarbeiter_strasse'] }}
{{ werte['mitarbeiter_plz'] }} {{ werte['mitarbeiter_ort'] }}

+

Kündigung des Dienstverhältnisses

+

{{ werte['ort_datum'] }}

+

{{ werte['anrede'] }},

+

leider sehen wir uns gezwungen, das mit Ihnen bestehende Dienstverhältnis ordentlich, fristgerecht aufzukündigen. Es endet demnach am {{ werte['beendigungstermin'] }}.

+

Die Kündigung erfolgt unter Einhaltung der für Ihr Dienstverhältnis geltenden Kündigungsfrist von {{ werte['kuendigung_frist'] }}.

+

Hinsichtlich Ihres bestehenden Resturlaubs bieten wir an, diesen in der Zeit von {{ werte['urlaub_rest'] }} zu verbrauchen.

+

Weiters teilen wir mit, dass wir Sie in der Zeit von {{ werte['freistellung'] }} bezahlt vom Dienst freistellen.

+

Um den Erhalt dieses Schreibens zu bestätigen, ersuchen wir Sie, die beiliegende Kopie zu unterfertigen und zurückzusenden.

+

{{ werte['firma'] }}

______________________
Dienstgeber

+

Erhalten am: ____________
Unterschrift Dienstnehmer:in: ______________________

+ + ]]>
+ +
+ + + + Einvernehmliche Auflösung + beendigung + lb-bnd-52, lb-bnd-62 + 2026-06 + entwurf + +Vereinbarung der einvernehmlichen Auflösung (Rahmen). Struktur nach dem +Lexis-Muster lb-bnd-62 (Stand 2026-06). Hinweis-Konvention des Musters: +Endtermin = letzter Arbeitstag, außer es wird Verbrauch von Zeitguthaben +oder Urlaub vereinbart; optionale Blöcke leer lassen = entfällt. + + +

{{ werte['firma'] }}, {{ werte['firma_strasse'] }}, {{ werte['firma_plz'] }} {{ werte['firma_ort'] }} — im Folgenden „der Dienstgeber“ —

+

und

+

{{ werte['mitarbeiter'] }}, {{ werte['mitarbeiter_strasse'] }}, {{ werte['mitarbeiter_plz'] }} {{ werte['mitarbeiter_ort'] }} — im Folgenden „die Dienstnehmerin/der Dienstnehmer“ —

+

Vereinbarung über die einvernehmliche Auflösung des Dienstverhältnisses

+

Die Parteien lösen das seit {{ werte['seit'] }} bestehende Dienstverhältnis einvernehmlich zum {{ werte['beendigungstermin'] }} auf. Mit diesem Tag endet das Dienstverhältnis; dieser Tag ist auch der letzte Arbeitstag.

+

Ein offenes Zeitguthaben von {{ werte['zeitguthaben'] }} wird in der Zeit von {{ werte['zeitguthaben_zeit'] }} verbraucht.

+

Offener Urlaub im Ausmaß von {{ werte['urlaub_rest'] }} wird in der Zeit von {{ werte['urlaub_zeit'] }} aufgebraucht.

+

Die Dienstnehmerin/der Dienstnehmer wird in der Zeit von {{ werte['freistellung'] }} bezahlt vom Dienst freigestellt.

+

Die letzte Entgeltabrechnung erfolgt zur gebührenden Ausfolgung mit der Beendigung; Urlaubsersatzleistungen und ausständige Beträge werden mit der Endabrechnung ausgewiesen.

+

Die Dienstnehmerin/der Dienstnehmer verpflichtet sich, sämtliche Betriebsgegenstände, Dokumente und Zugänge bis zum Ende des Dienstverhältnisses zurückzustellen.

+

Sämtliche gegenseitigen Ansprüche aus dem Dienstverhältnis — soweit gesetzlich zulässig und abweichend nicht geregelt — gelten mit diesem Tag als abgegolten.

+

Änderungen dieser Vereinbarung bedürfen der Schriftform.

+

Ort, Datum: {{ werte['ort_datum'] }}

Dienstgeber: ________________________

Dienstnehmer:in: ______________________

+ + ]]>
+ +
+ + + + Vordienstzeitenbestätigung + bescheinigung + lb-end-35 + 2026-07 + entwurf + +Bestätigung des Vor-Dienstgebers über Beschäftigungsdauer und Abfertigungs- +Status anlässlich der Beendigung (Anrechnung von Vordienstzeiten beim +neuen Dienstgeber, lb-end-35, Stand 2026-07). Abfertigungs-Auswahl im +Wizard befüllen (keine/gesetzliche/KV/freiwillige — nicht zutreffende +Angaben entfallen). + + +

{{ werte['firma'] }}, {{ werte['firma_strasse'] }}, {{ werte['firma_plz'] }} {{ werte['firma_ort'] }}

+

Bestätigung über Vordienstzeiten

+

Wir, {{ werte['firma'] }}, bestätigen, dass {{ werte['mitarbeiter'] }}, geboren am {{ werte['geboren'] }}, in der Zeit von {{ werte['von'] }} bis {{ werte['bis'] }} bei uns beschäftigt war und anlässlich der Beendigung des Dienstverhältnisses:

+
    +
  • {{ werte['abfertigung_art'] }}
  • +
+

Die Bestätigung dient der Anrechnung von Vordienstzeiten für die Einstufung beim neuen Dienstgeber.

+

Datum: {{ werte['ort_datum'] }}

+

firmenmäßige Zeichnung: ________________________

+ + ]]>
+ +
+
\ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/models/__init__.py b/addons/l10n_at_payroll_dokumente/models/__init__.py new file mode 100644 index 0000000..1f3f46e --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/models/__init__.py @@ -0,0 +1,4 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import dokument_vorlage +from . import dokument_wizard +from . import dokument_report \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/models/dokument_report.py b/addons/l10n_at_payroll_dokumente/models/dokument_report.py new file mode 100644 index 0000000..dbfc9f2 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/models/dokument_report.py @@ -0,0 +1,17 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Generisches Druck-Template: nimmt das vom Wizard gerenderte +body-HTML entgegen und bettet es in die PDF-Seite ein.""" +from markupsafe import Markup +from odoo import models + + +class ReportDokumentGenerisch(models.AbstractModel): + _name = 'report.l10n_at_payroll_dokumente.report_dokument_generisch' + _description = 'Dokument aus Vorlage (generisches Drucklayout)' + + def _get_report_values(self, docids, data=None): + data = data or {} + return { + 'titel': data.get('titel', ''), + 'body': Markup(data.get('body', '')), + } \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/models/dokument_vorlage.py b/addons/l10n_at_payroll_dokumente/models/dokument_vorlage.py new file mode 100644 index 0000000..a673396 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/models/dokument_vorlage.py @@ -0,0 +1,118 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Dokumentgenerierung: Vorlagen mit eigenem Text und Platzhaltern. + +Vorlagen enthalten ausschließlich eigenen Text; die Struktur-Referenz zu +den Lexis-Mustern ist pro Vorlage dokumentiert (``kb_ref``/``quell_stand``), +nie der lizenzierte Volltext. + +Rendering: eigene Ausführung über ``ir.qweb`` mit den Framework-Bausteinen +``rendering_tools.convert_inline_template_to_qweb`` — Autor-Syntax bleibt +``{{ werte['key'] }}`` (identisch zu mail.render.mixin), t-out escapt die +Werte automatisch. Bewusst NICHT ``mail.render.mixin._render_template`` +mit engine='inline_template': deren Ausdrucks-Whitelist +(``mail_allowed_qweb_expressions``) lässt nur ``object.*``-Basistreffer zu +und würde die ``werte['...']``-Platzhalter für Nicht-Template-Editoren +sperren (AccessError). +""" +from odoo import fields, models +from odoo.tools.rendering_tools import convert_inline_template_to_qweb + +from lxml import html as lxml_html + + +class L10nAtPayrollDokumentVorlage(models.Model): + _name = 'l10n.at.payroll.dokument.vorlage' + _description = 'Dokumentvorlage (eigener Text)' + _order = 'thema, name' + + name = fields.Char(required=True) + thema = fields.Selection( + [('dienstvertrag', 'Dienstvertrag'), + ('beendigung', 'Beendigung/Kündigung'), + ('bescheinigung', 'Bescheinigung'), + ('vereinbarung', 'Vereinbarung'), + ('exekution', 'Exekution/Pfändung'), + ('sonstiges', 'Sonstiges')], + required=True, default='sonstiges') + beschreibung = fields.Text( + help="Zweck des Dokuments und Hinweise zum Rechtsanker; auch " + "Platzhalter-Dokumentation für nicht in der Feldliste " + "geführte Ausdrücke (object.*, version.*).") + inhalt_html = fields.Html( + string='Vorlagentext', + sanitize=False, + help="Eigener Vorlagentext; Platzhalter {{ werte['key'] }} werden " + "im Generierungs-Wizard befüllt.") + feld_ids = fields.One2many( + 'l10n.at.payroll.dokument.feld', 'vorlage_id', + string='Befüllfelder') + kb_ref = fields.Char( + string='Struktur-Referenz', + help="Wissensbasis-/Muster-ID, nach deren Struktur der eigene " + "Text formuliert wurde (Herkunftsnachweis, kein Volltext).") + quell_stand = fields.Char( + string='Quell-Stand', + help="Stand der Struktur-Referenz, z. B. '2025-06'.") + rechtsstatus = fields.Selection( + [('entwurf', 'Entwurf — rechtliche Prüfung offen'), + ('geprueft', 'Rechtlich geprüft')], + default='entwurf', required=True, string='Rechtsstatus') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', default=lambda self: self.env.company) + + def _at_renderen(self, employee, version, werte, datum): + """Vorlagentext auf den Mitarbeiter rendern (HTML-String).""" + self.ensure_one() + variablen = self.env['mail.render.mixin']._render_eval_context() + variablen.update({ + 'werte': werte, + 'version': version, + 'datum': datum, + 'object': employee, + }) + qweb_src = convert_inline_template_to_qweb(self.inhalt_html) + element = lxml_html.fragment_fromstring( + str(qweb_src), create_parent='div') + html = self.env['ir.qweb']._render( + element, variablen, minimal_qcontext=True) + # Ummantelungs-
von fragment_fromstring entfernen + # (Muster mail.render.mixin._render_template_qweb). + return str(html)[5:-6] + + _sql_constraints = [ + ('name_uniq', 'unique(name, company_id)', + 'Vorlagenname je Company eindeutig.'), + ] + + +class L10nAtPayrollDokumentFeld(models.Model): + _name = 'l10n.at.payroll.dokument.feld' + _description = 'Dokumentvorlage-Befüllfeld' + _order = 'sequence, id' + _rec_name = 'key' + + sequence = fields.Integer(default=10) + vorlage_id = fields.Many2one( + 'l10n.at.payroll.dokument.vorlage', required=True, + ondelete='cascade', index=True) + key = fields.Char( + required=True, + help="Platzhalter-Schlüssel; im Vorlagentext als " + "{{ werte['key'] }} zu verwenden.") + bezeichnung = fields.Char() + quelle = fields.Selection( + [('auto', 'Automatisch (hr-Stammdaten)'), + ('manuell', 'Manuelle Eingabe im Wizard')], + default='manuell', required=True) + pflicht = fields.Boolean( + default=True, + help="Pflichtfelder müssen im Wizard befüllt sein, bevor das " + "Dokument erzeugt wird.") + hinweis = fields.Char( + help="Erklärung für die Wizard-Eingabe (z. B. KV-Bezug).") + + _sql_constraints = [ + ('key_vorlage_uniq', 'unique(vorlage_id, key)', + 'Platzhalter-Schlüssel je Vorlage eindeutig.'), + ] \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/models/dokument_wizard.py b/addons/l10n_at_payroll_dokumente/models/dokument_wizard.py new file mode 100644 index 0000000..3f47de7 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/models/dokument_wizard.py @@ -0,0 +1,188 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Wizard: Dokument aus Vorlage generieren. + +Befüllt die Platzhalter der Vorlage aus hr-Stammdaten (automatische +Felder) und Wizard-Eingaben (manuelle Felder), rendert eine Vorschau und +erzeugt PDF (ir.actions.report, wkhtmltopdf) bzw. — ohne PDF-Engine — +das gerenderte HTML als Anhang. +""" +import base64 + +from markupsafe import Markup +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools.misc import format_date + + +class L10nAtPayrollDokumentWizard(models.TransientModel): + _name = 'l10n.at.payroll.dokument.wizard' + _description = 'Dokument aus Vorlage generieren' + + vorlage_id = fields.Many2one( + 'l10n.at.payroll.dokument.vorlage', required=True, + domain=[('active', '=', True)]) + employee_id = fields.Many2one('hr.employee', required=True) + datum = fields.Date( + string='Stichtag', + default=fields.Date.context_today, + help="Stichtag für den hr.version-Stand (Vertragsdaten).") + version_id = fields.Many2one( + 'hr.version', compute='_compute_version_id', + string='Vertragsstand (hr.version)') + feld_wert_ids = fields.One2many( + 'l10n.at.payroll.dokument.wizard.feld', 'wizard_id', + string='Befüllung') + vorschau_html = fields.Html( + string='Vorschau', readonly=True, sanitize=False) + + @api.depends('employee_id', 'datum') + def _compute_version_id(self): + for wizard in self: + wizard.version_id = wizard._at_version_am() + + def _at_version_am(self): + """hr.version gültig am Stichtag (date_version <= Stichtag).""" + self.ensure_one() + if not self.datum or not self.employee_id: + return self.env['hr.version'] + return self.env['hr.version'].search( + [('employee_id', '=', self.employee_id.id), + ('date_version', '<=', self.datum)], + order='date_version desc, id desc', limit=1) + + @api.onchange('vorlage_id') + def _onchange_vorlage_id(self): + self.vorschau_html = False + self.feld_wert_ids = self._at_linien() + + def action_befuellen(self): + """Linien aus der Vorlage neu aufbauen (auch bei Wechsel).""" + self.ensure_one() + self.vorschau_html = False + self.feld_wert_ids = self._at_linien() + + def _at_linien(self): + self.ensure_one() + if not self.vorlage_id: + return [(5, 0, 0)] + return [(5, 0, 0)] + [ + (0, 0, { + 'key': feld.key, + 'bezeichnung': feld.bezeichnung, + 'quelle': feld.quelle, + 'pflicht': feld.pflicht, + 'hinweis': feld.hinweis, + }) + for feld in self.vorlage_id.feld_ids.sorted('sequence') + ] + + def _at_werte(self): + """Befüllwerte: automatisch aufgelöst + manuelle Eingaben.""" + self.ensure_one() + werte = {} + fehlen = [] + for feld in self.vorlage_id.feld_ids: + line = self.feld_wert_ids.filtered( + lambda l, k=feld.key: l.key == k)[:1] + if feld.quelle == 'auto': + werte[feld.key] = self._at_auto_wert(feld.key) + else: + wert = (line.wert or '').strip() + if not wert and feld.pflicht: + fehlen.append(feld.bezeichnung or feld.key) + werte[feld.key] = wert + if fehlen: + raise UserError(_( + "Pflichtfelder nicht befüllt: %s", ', '.join(fehlen))) + return werte + + def _at_auto_wert(self, key): + self.ensure_one() + employee = self.employee_id + version = self.version_id + company = employee.company_id + if key == 'firma': + return company.name or '' + if key == 'firma_strasse': + return company.partner_id.street or '' + if key == 'firma_plz': + return company.partner_id.zip or '' + if key == 'firma_ort': + return company.partner_id.city or '' + if key == 'mitarbeiter': + return employee.name or '' + if key == 'mitarbeiter_strasse': + return version.private_street or '' + if key == 'mitarbeiter_plz': + return version.private_zip or '' + if key == 'mitarbeiter_ort': + return version.private_city or '' + if key == 'ead': + datum = version.date_version or version.contract_date_start + return format_date(self.env, datum) if datum else '' + if key == 'funktion': + return version.job_title or version.job_id.name or '' + if key == 'geboren': + return format_date(self.env, employee.birthday) if employee.birthday else '' + raise UserError(_( + "Kein automatischer Wert für Platzhalter '%s' definiert.", key)) + + def action_vorschau(self): + self.ensure_one() + werte = self._at_werte() + self.vorschau_html = Markup(self.vorlage_id._at_renderen( + self.employee_id, self.version_id, werte, self.datum)) + return { + 'type': 'ir.actions.act_window', + 'res_model': self._name, + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + } + + def action_pdf(self): + self.ensure_one() + werte = self._at_werte() + body = Markup(self.vorlage_id._at_renderen( + self.employee_id, self.version_id, werte, self.datum)) + name = '%s — %s (%s)' % ( + self.vorlage_id.name, self.employee_id.name, self.datum) + if self.env['ir.actions.report'].get_wkhtmltopdf_state() == 'ok': + pdf_content, _typ = self.env['ir.actions.report']._render_qweb_pdf( + 'l10n_at_payroll_dokumente.action_report_dokument_generisch', + self.ids, data={'titel': name, 'body': body}) + datas = base64.b64encode(pdf_content) + mimetype = 'application/pdf' + suffix = '.pdf' + else: + datas = base64.b64encode(str(body).encode('utf-8')) + mimetype = 'text/html' + suffix = '.html' + self.env['ir.attachment'].create({ + 'name': name + suffix, + 'datas': datas, + 'mimetype': mimetype, + 'res_model': 'hr.employee', + 'res_id': self.employee_id.id, + 'description': 'Dokument aus Vorlage %s' % self.vorlage_id.name, + }) + return { + 'type': 'ir.actions.act_window_close', + } + + +class L10nAtPayrollDokumentWizardFeld(models.TransientModel): + _name = 'l10n.at.payroll.dokument.wizard.feld' + _description = 'Dokument-Wizard Befüllzeile' + + wizard_id = fields.Many2one( + 'l10n.at.payroll.dokument.wizard', required=True, + ondelete='cascade') + key = fields.Char(readonly=True, required=True) + bezeichnung = fields.Char(readonly=True) + quelle = fields.Selection( + [('auto', 'Automatisch (hr-Stammdaten)'), + ('manuell', 'Manuelle Eingabe im Wizard')], readonly=True) + pflicht = fields.Boolean(readonly=True) + hinweis = fields.Char(readonly=True) + wert = fields.Char() \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/report/report_dokument.xml b/addons/l10n_at_payroll_dokumente/report/report_dokument.xml new file mode 100644 index 0000000..f3fcc4e --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/report/report_dokument.xml @@ -0,0 +1,23 @@ + + + + + + + Dokument aus Vorlage + l10n.at.payroll.dokument.wizard + qweb-pdf + l10n_at_payroll_dokumente.report_dokument_generisch + l10n_at_payroll_dokumente.report_dokument_generisch + + + + + \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/security/ir.model.access.csv b/addons/l10n_at_payroll_dokumente/security/ir.model.access.csv new file mode 100644 index 0000000..25e0c0e --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_dokument_vorlage_hr_user,l10n.at.payroll.dokument.vorlage hr.user,model_l10n_at_payroll_dokument_vorlage,hr.group_hr_user,1,0,0,0 +access_dokument_vorlage_hr_manager,l10n.at.payroll.dokument.vorlage hr.manager,model_l10n_at_payroll_dokument_vorlage,hr.group_hr_manager,1,1,1,1 +access_dokument_feld_hr_user,l10n.at.payroll.dokument.feld hr.user,model_l10n_at_payroll_dokument_feld,hr.group_hr_user,1,0,0,0 +access_dokument_feld_hr_manager,l10n.at.payroll.dokument.feld hr.manager,model_l10n_at_payroll_dokument_feld,hr.group_hr_manager,1,1,1,1 \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/security/security_rules.xml b/addons/l10n_at_payroll_dokumente/security/security_rules.xml new file mode 100644 index 0000000..56ba4a6 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/security/security_rules.xml @@ -0,0 +1,8 @@ + + + + Dokumentvorlage: eigene Company + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/tests/__init__.py b/addons/l10n_at_payroll_dokumente/tests/__init__.py new file mode 100644 index 0000000..695cc8e --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import test_dokumente \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/tests/test_dokumente.py b/addons/l10n_at_payroll_dokumente/tests/test_dokumente.py new file mode 100644 index 0000000..290c4ad --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/tests/test_dokumente.py @@ -0,0 +1,183 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""Tests: Dokumentgenerierung aus Vorlagen. + +Rendering der eigenen Vorlagentexte (werte['key']-Platzhalter), Auto-Werte +aus hr-Stammdaten, Pflichtfeld-Validierung, hr.version-Stichtagsauflösung, +generisches Druck-Template und Anhang-Ablage (PDF wenn wkhtmltopdf +verfügbar, sonst HTML-Fallback).""" +import base64 +import datetime + +from odoo.exceptions import AccessError, UserError +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class DokumenteTest(TransactionCase): + + def setUp(self): + super().setUp() + self.company = self.env.company + self.company.write({'name': 'Musterwerkstatt GmbH'}) + employee = self.env['hr.employee'].create({ + 'name': 'Dokument Test'}) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + version.write({ + 'date_version': datetime.date(2020, 1, 1), + 'contract_date_start': datetime.date(2020, 1, 1), + 'job_title': 'Techniker', + 'private_street': 'Testgasse 1', + 'private_zip': '1010', + 'private_city': 'Wien', + }) + self.employee = employee + self.version = version + self.vorlage = self.env['l10n.at.payroll.dokument.vorlage'].create({ + 'name': 'Testbescheinigung', + 'thema': 'bescheinigung', + 'kb_ref': 'lb-test', + 'rechtsstatus': 'entwurf', + 'inhalt_html': + '

Firma: {{ werte[\'firma\'] }}

' + '

Mitarbeiter: {{ werte[\'mitarbeiter\'] }}

' + '

Funktion: {{ werte[\'funktion\'] }}

' + '

EAD: {{ werte[\'ead\'] }}

' + '

Zusatz: {{ werte[\'zusatz\'] }}

', + 'feld_ids': [ + (0, 0, {'sequence': 1, 'key': 'zusatz', + 'quelle': 'manuell', 'pflicht': True, + 'bezeichnung': 'Zusatzangabe'}), + (0, 0, {'sequence': 2, 'key': 'firma', 'quelle': 'auto', + 'pflicht': True}), + (0, 0, {'sequence': 3, 'key': 'mitarbeiter', + 'quelle': 'auto', 'pflicht': True}), + (0, 0, {'sequence': 4, 'key': 'funktion', 'quelle': 'auto', + 'pflicht': False}), + (0, 0, {'sequence': 5, 'key': 'ead', 'quelle': 'auto', + 'pflicht': False}), + ], + }) + + def _wizard(self): + wizard = self.env['l10n.at.payroll.dokument.wizard'].create({ + 'vorlage_id': self.vorlage.id, + 'employee_id': self.employee.id, + 'datum': datetime.date(2026, 1, 15), + }) + wizard.action_befuellen() + return wizard + + def test_01_auto_werte(self): + wizard = self._wizard() + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'zusatz').wert = 'X' + werte = wizard._at_werte() + self.assertEqual(werte['firma'], 'Musterwerkstatt GmbH') + self.assertEqual(werte['mitarbeiter'], 'Dokument Test') + self.assertEqual(werte['funktion'], 'Techniker') + self.assertTrue(werte['ead']) + + def test_02_vorschau_ersetzt_platzhalter(self): + wizard = self._wizard() + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'zusatz').wert = 'Projekt Alpha' + wizard.action_vorschau() + vorschau = wizard.vorschau_html + self.assertIn('Musterwerkstatt GmbH', vorschau) + self.assertIn('Dokument Test', vorschau) + self.assertIn('Projekt Alpha', vorschau) + self.assertNotIn('{{', vorschau) + + def test_03_pflichtfeld_leist_user_error(self): + wizard = self._wizard() + with self.assertRaises(UserError): + wizard._at_werte() + + def test_04_version_am_stichtag(self): + self.env['hr.version'].create({ + 'employee_id': self.employee.id, + 'date_version': datetime.date(2026, 3, 1), + 'job_title': 'Senior Techniker', + }) + wizard = self._wizard() + self.assertEqual(wizard.version_id.job_title, 'Techniker') + wizard.datum = datetime.date(2026, 6, 1) + wizard._compute_version_id() + self.assertEqual(wizard.version_id.job_title, 'Senior Techniker') + + def test_05_pdf_ablegt_anhang(self): + wizard = self._wizard() + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'zusatz').wert = 'Fall 5' + wizard.action_pdf() + anh = self.env['ir.attachment'].search( + [('res_model', '=', 'hr.employee'), + ('res_id', '=', self.employee.id)]) + self.assertEqual(len(anh), 1) + self.assertIn(anh.mimetype, ('application/pdf', 'text/html')) + if anh.mimetype == 'text/html': + html = base64.b64decode(anh.datas).decode('utf-8') + self.assertIn('Fall 5', html) + + def test_06_report_values_generic(self): + wizard = self._wizard() + report = self.env[ + 'report.l10n_at_payroll_dokumente' + '.report_dokument_generisch'] + values = report._get_report_values( + wizard.ids, data={'titel': 'T', 'body': '

Body

'}) + self.assertEqual(str(values['body']), '

Body

') + html_content, _typ = self.env['ir.actions.report']._render_qweb_html( + 'l10n_at_payroll_dokumente.action_report_dokument_generisch', + wizard.ids, data={'titel': 'T', 'body': values['body']}) + self.assertIn('Body', str(html_content)) + + def test_07_access_hr_user_lesen_manager_schreiben(self): + hr_user = self.env['res.users'].create({ + 'name': 'Dokument Lesetest', + 'login': 'dok_lesetest', + 'group_ids': [(4, self.env.ref('hr.group_hr_user').id)], + }) + umgebung = self.env(user=hr_user.id) + self.assertTrue( + umgebung['l10n.at.payroll.dokument.vorlage'].browse( + self.vorlage.id).name) + kein_hr = self.env['res.users'].create({ + 'name': 'Dokument Ohne HR', + 'login': 'dok_ohne_hr', + 'group_ids': [(4, self.env.ref('base.group_user').id)], + }) + ohne = self.env(user=kein_hr.id) + with self.assertRaises(AccessError): + ohne['l10n.at.payroll.dokument.vorlage'].browse( + self.vorlage.id).name + + def test_08_pilot_vorlagen_installiert(self): + for xmlid in ('vorlage_dienstvertrag_angestellte', + 'vorlage_kuendigung_dienstgeber', + 'vorlage_einvernehmliche_aufloesung', + 'vorlage_vordienstzeiten'): + vorlage = self.env.ref( + 'l10n_at_payroll_dokumente.%s' % xmlid) + self.assertTrue(vorlage.inhalt_html) + self.assertEqual(vorlage.rechtsstatus, 'entwurf') + kuendigung = self.env.ref( + 'l10n_at_payroll_dokumente.vorlage_kuendigung_dienstgeber') + wizard = self.env['l10n.at.payroll.dokument.wizard'].create({ + 'vorlage_id': kuendigung.id, + 'employee_id': self.employee.id, + 'datum': datetime.date(2026, 9, 1), + }) + wizard.action_befuellen() + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'anrede').wert = 'Sehr geehrter Herr' + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'kuendigung_frist').wert = '6 Wochen' + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'beendigungstermin').wert = '31.10.2026' + wizard.feld_wert_ids.filtered( + lambda l: l.key == 'ort_datum').wert = 'Wien, 1.9.2026' + wizard.action_vorschau() + self.assertIn('31.10.2026', wizard.vorschau_html) + self.assertIn('Musterwerkstatt GmbH', wizard.vorschau_html) \ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/views/dokument_vorlage_views.xml b/addons/l10n_at_payroll_dokumente/views/dokument_vorlage_views.xml new file mode 100644 index 0000000..b5e2a7e --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/views/dokument_vorlage_views.xml @@ -0,0 +1,92 @@ + + + + l10n.at.payroll.dokument.vorlage.list + l10n.at.payroll.dokument.vorlage + + + + + + + + + + + + + + l10n.at.payroll.dokument.vorlage.form + l10n.at.payroll.dokument.vorlage + +
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Dokumentvorlagen + l10n.at.payroll.dokument.vorlage + list,form + {'default_active': True} + +

+ Erste Dokumentvorlage anlegen +

+

+ Eigener Text mit Platzhaltern {{ werte['key'] }}; die + Befüllfelder steuern, was der Generierungs-Wizard abfragt. +

+
+
+ + + +
\ No newline at end of file diff --git a/addons/l10n_at_payroll_dokumente/views/wizard_views.xml b/addons/l10n_at_payroll_dokumente/views/wizard_views.xml new file mode 100644 index 0000000..df4fd23 --- /dev/null +++ b/addons/l10n_at_payroll_dokumente/views/wizard_views.xml @@ -0,0 +1,68 @@ + + + + l10n.at.payroll.dokument.wizard.form + l10n.at.payroll.dokument.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Dokument aus Vorlage generieren + l10n.at.payroll.dokument.wizard + form + new + + + + Dokument aus Vorlage generieren + l10n.at.payroll.dokument.wizard + form + new + {'default_employee_id': active_id} + + list,form + + + +
\ No newline at end of file