Compare commits

..

2 Commits

Author SHA1 Message Date
fegger 1df5fb19c4 [ADD] l10n_at_payroll_dokumente: generate documents from templates
Document generation module: templates with own text (structure reference
and source stand documented per template, no licensed full text in the
repo), fill fields resolved from hr data (company, employee, hr.version
stand) plus wizard input, preview rendering and PDF/HTML attachment via
the generic report layout.

- templates: pilot set for the general-AT track formulated as own text
  after the KB muster structure (lb-bes-09, lb-bnd-55, lb-bnd-52/62,
  lb-end-35), legal status per template marked as draft
- rendering via convert_inline_template_to_qweb + ir.qweb (t-out
  auto-escapes values) — deliberately not mail.render.mixin inline
  engine, whose expression whitelist blocks werte[...] placeholders for
  non-template-editors
- wizard resolves hr.version by date_version at the reference date;
  PDF falls back to HTML attachment without wkhtmltopdf
- security: ir.model.access (hr user read / manager write) + company
  rule; 8 tests, suite 236/236 green on a fresh -i database run
2026-09-16 10:36:49 +02:00
fegger efe76e6b3d [FIX] personalverrechnung: record Ausbildungskostenrückersatz gap in AP14 plan
Renewed knowledge-base comparison (2026-09-16, Batch 10) surfaced a new
settlement component not covered by the 2026-09-12 gap analysis:
Ausbildungskostenrückersatz at early termination (lb-end-27/28). Added to
the AP14 scope, anchors and gap list; RIS verification of the legal basis
gates implementation, scope gate at the pilot client.
2026-09-16 10:36:42 +02:00
17 changed files with 1058 additions and 2 deletions
+71
View File
@@ -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/AP11AP16.
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"):
+1
View File
@@ -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
@@ -0,0 +1,3 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import models
from . import tests
@@ -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,
}
@@ -0,0 +1,232 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- Pilot-Vorlagen: EIGENER Text, strukturiert nach den -->
<!-- Wissensbasis-Mustern (kb_ref = Herkunftsnachweis der -->
<!-- Struktur, nie lizenzierter Volltext). Rechtsstatus 'entwurf' -->
<!-- — rechtliche Prüfung vor Produktivsetzung erforderlich. -->
<!-- ============================================================ -->
<!-- ================= Dienstvertrag (Angestellte) ============== -->
<record id="vorlage_dienstvertrag_angestellte" model="l10n.at.payroll.dokument.vorlage">
<field name="name">Dienstvertrag (Angestellte)</field>
<field name="thema">dienstvertrag</field>
<field name="kb_ref">lb-bes-09</field>
<field name="quell_stand">2025-06</field>
<field name="rechtsstatus">entwurf</field>
<field name="beschreibung">
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.
</field>
<field name="inhalt_html"><![CDATA[
<div>
<p>{{ werte['firma'] }}<br/>{{ werte['firma_strasse'] }}<br/>{{ werte['firma_plz'] }} {{ werte['firma_ort'] }}</p>
<h2>Dienstvertrag</h2>
<p>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“.</p>
<h3>I. Tätigkeitsbereich</h3>
<p>1. Der Dienstnehmer wird ab {{ werte['ead'] }} als {{ werte['funktion'] }} angestellt. Übertragen werden ihm insbesondere folgende Tätigkeiten:<br/>{{ werte['taetigkeit'] }}</p>
<p>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.</p>
<h3>II. Dienstort</h3>
<p>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.</p>
<h3>III. Einstufung</h3>
<p>Die Einstufung erfolgt gemäß den anwendbaren kollektivvertraglichen Bestimmungen: {{ werte['einstufung'] }}.</p>
<h3>IV. Entgelt</h3>
<p>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'] }}).</p>
<p>2. Sonderzahlungen gebühren gemäß den anwendbaren kollektivvertraglichen Bestimmungen; die Abrechnung erfolgt nach den jeweils geltenden lohnsteuerlichen Vorgaben.</p>
<h3>V. Arbeitszeit, Mehr- und Überstunden</h3>
<p>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.</p>
<p>2. Mehr- und Überstunden sind nach den Bestimmungen des Arbeitszeitgesetzes zu leisten; der Abgeltung liegen die anwendbaren kollektivvertraglichen Bestimmungen zugrunde.</p>
<h3>VI. Spesen</h3>
<p>Spesen im Zusammenhang mit der Dienstausübung werden nach den geltenden Regelungen des Dienstgebers abgerechnet.</p>
<h3>VII. Urlaub</h3>
<p>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.</p>
<h3>VIII. Entgeltfortzahlung</h3>
<p>Bei Dienstverhinderung gelten die gesetzlichen Bestimmungen über die Entgeltfortzahlung.</p>
<h3>IX. Dauer</h3>
<p>1. Das Dienstverhältnis beginnt am {{ werte['ead'] }} und wird auf unbestimmte Zeit geschlossen.</p>
<p>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'] }}.</p>
<div t-if="werte['geheimhaltung']">
<h3>X. Geheimhaltung</h3>
<p>{{ werte['geheimhaltung'] }}</p>
</div>
<div t-if="werte['konkurrenzklausel']">
<h3>XI. Nebentätigkeit/Konkurrenzklausel</h3>
<p>{{ werte['konkurrenzklausel'] }}</p>
</div>
<h3>XII. Schlussbestimmungen</h3>
<p>1. Abfertigungsansprüche richten sich nach den Bestimmungen des BMSVG.</p>
<p>2. Nebenabreden und Änderungen dieses Vertrages bedürfen der Schriftform; mündliche Nebenabreden bestehen nicht.</p>
<p>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.</p>
<p>Ort, Datum: {{ werte['ort_datum'] }}<br/><br/>Dienstgeber: ________________________<br/><br/>Dienstnehmer: ______________________</p>
</div>
]]></field>
<field name="feld_ids" eval="[
Command.create({'sequence': 1, 'key': 'taetigkeit', 'bezeichnung': 'Tätigkeiten (mehrere Zeilen möglich)', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 2, 'key': 'dienstort', 'bezeichnung': 'Dienstort', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 3, 'key': 'einstufung', 'bezeichnung': 'Einstufung (KV/Gruppe/Stufe)', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 3, 'key': 'entgelt', 'bezeichnung': 'Monatsentgelt brutto (EUR)', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 4, 'key': 'kv_verweis', 'bezeichnung': 'KV-Verweis (z. B. KV Angestellte, Ausgabe ...)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 5, 'key': 'arbeitszeit', 'bezeichnung': 'Wöchentliche Dienstzeit (z. B. 40 Stunden)', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 6, 'key': 'urlaub_tage', 'bezeichnung': 'Urlaubstage je Dienstjahr', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 6, 'key': 'kuendigung_frist', 'bezeichnung': 'Kündigungsfrist', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 7, 'key': 'geheimhaltung', 'bezeichnung': 'Geheimhaltungsblock (leer = Block entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 8, 'key': 'konkurrenzklausel', 'bezeichnung': 'Nebentätigkeit/Konkurrenzklausel (leer = Block entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 9, 'key': 'ort_datum', 'bezeichnung': 'Ort, Datum', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma', 'bezeichnung': 'Firmenname', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 11, 'key': 'firma_strasse', 'bezeichnung': 'Firma: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 11, 'key': 'firma_plz', 'bezeichnung': 'Firma: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 12, 'key': 'firma_ort', 'bezeichnung': 'Firma: Ort', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 13, 'key': 'mitarbeiter', 'bezeichnung': 'Dienstnehmer:in (Name)', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 14, 'key': 'mitarbeiter_strasse', 'bezeichnung': 'Dienstnehmer:in: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 15, 'key': 'mitarbeiter_plz', 'bezeichnung': 'Dienstnehmer:in: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 16, 'key': 'mitarbeiter_ort', 'bezeichnung': 'Dienstnehmer:in: Ort', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 17, 'key': 'ead', 'bezeichnung': 'Dienstantritt (EAD)', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 18, 'key': 'funktion', 'bezeichnung': 'Funktion (Job)', 'quelle': 'auto', 'pflicht': False}),
]"/>
</record>
<!-- ================= Kündigung (Dienstgeber) ================== -->
<record id="vorlage_kuendigung_dienstgeber" model="l10n.at.payroll.dokument.vorlage">
<field name="name">Kündigung durch den Dienstgeber</field>
<field name="thema">beendigung</field>
<field name="kb_ref">lb-bnd-55</field>
<field name="quell_stand">2026-07</field>
<field name="rechtsstatus">entwurf</field>
<field name="beschreibung">
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.
</field>
<field name="inhalt_html"><![CDATA[
<div>
<p>{{ werte['firma'] }}<br/>{{ werte['firma_strasse'] }}<br/>{{ werte['firma_plz'] }} {{ werte['firma_ort'] }}</p>
<p>An {{ werte['anrede'] }} {{ werte['mitarbeiter'] }}<br/>{{ werte['mitarbeiter_strasse'] }}<br/>{{ werte['mitarbeiter_plz'] }} {{ werte['mitarbeiter_ort'] }}</p>
<h2>Kündigung des Dienstverhältnisses</h2>
<p>{{ werte['ort_datum'] }}</p>
<p>{{ werte['anrede'] }},</p>
<p>leider sehen wir uns gezwungen, das mit Ihnen bestehende Dienstverhältnis ordentlich, fristgerecht aufzukündigen. Es endet demnach am {{ werte['beendigungstermin'] }}.</p>
<p>Die Kündigung erfolgt unter Einhaltung der für Ihr Dienstverhältnis geltenden Kündigungsfrist von {{ werte['kuendigung_frist'] }}.</p>
<p t-if="werte['urlaub_rest']">Hinsichtlich Ihres bestehenden Resturlaubs bieten wir an, diesen in der Zeit von {{ werte['urlaub_rest'] }} zu verbrauchen.</p>
<p t-if="werte['freistellung']">Weiters teilen wir mit, dass wir Sie in der Zeit von {{ werte['freistellung'] }} bezahlt vom Dienst freistellen.</p>
<p>Um den Erhalt dieses Schreibens zu bestätigen, ersuchen wir Sie, die beiliegende Kopie zu unterfertigen und zurückzusenden.</p>
<p>{{ werte['firma'] }}<br/><br/>______________________<br/>Dienstgeber</p>
<p>Erhalten am: ____________<br/>Unterschrift Dienstnehmer:in: ______________________</p>
</div>
]]></field>
<field name="feld_ids" eval="[
Command.create({'sequence': 1, 'key': 'anrede', 'bezeichnung': 'Anrede (Herr/Frau/...)', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 2, 'key': 'kuendigung_frist', 'bezeichnung': 'Kündigungsfrist', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 3, 'key': 'beendigungstermin', 'bezeichnung': 'Endtermin', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 4, 'key': 'urlaub_rest', 'bezeichnung': 'Resturlaub-Verbrauch (Zeitraum, leer = entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 5, 'key': 'freistellung', 'bezeichnung': 'Bezahlte Freistellung (Zeitraum, leer = entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 6, 'key': 'ort_datum', 'bezeichnung': 'Ort, Datum', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma', 'bezeichnung': 'Firmenname', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma_strasse', 'bezeichnung': 'Firma: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'firma_plz', 'bezeichnung': 'Firma: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'firma_ort', 'bezeichnung': 'Firma: Ort', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter', 'bezeichnung': 'Dienstnehmer:in (Name)', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_strasse', 'bezeichnung': 'Dienstnehmer:in: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_plz', 'bezeichnung': 'Dienstnehmer:in: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_ort', 'bezeichnung': 'Dienstnehmer:in: Ort', 'quelle': 'auto', 'pflicht': False}),
]"/>
</record>
<!-- ============ Einvernehmliche Auflösung ==================== -->
<record id="vorlage_einvernehmliche_aufloesung" model="l10n.at.payroll.dokument.vorlage">
<field name="name">Einvernehmliche Auflösung</field>
<field name="thema">beendigung</field>
<field name="kb_ref">lb-bnd-52, lb-bnd-62</field>
<field name="quell_stand">2026-06</field>
<field name="rechtsstatus">entwurf</field>
<field name="beschreibung">
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.
</field>
<field name="inhalt_html"><![CDATA[
<div>
<p>{{ werte['firma'] }}, {{ werte['firma_strasse'] }}, {{ werte['firma_plz'] }} {{ werte['firma_ort'] }} — im Folgenden „der Dienstgeber“ —</p>
<p>und</p>
<p>{{ werte['mitarbeiter'] }}, {{ werte['mitarbeiter_strasse'] }}, {{ werte['mitarbeiter_plz'] }} {{ werte['mitarbeiter_ort'] }} — im Folgenden „die Dienstnehmerin/der Dienstnehmer“ —</p>
<h2>Vereinbarung über die einvernehmliche Auflösung des Dienstverhältnisses</h2>
<p>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.</p>
<p t-if="werte['zeitguthaben']">Ein offenes Zeitguthaben von {{ werte['zeitguthaben'] }} wird in der Zeit von {{ werte['zeitguthaben_zeit'] }} verbraucht.</p>
<p t-if="werte['urlaub_rest']">Offener Urlaub im Ausmaß von {{ werte['urlaub_rest'] }} wird in der Zeit von {{ werte['urlaub_zeit'] }} aufgebraucht.</p>
<p t-if="werte['freistellung']">Die Dienstnehmerin/der Dienstnehmer wird in der Zeit von {{ werte['freistellung'] }} bezahlt vom Dienst freigestellt.</p>
<p>Die letzte Entgeltabrechnung erfolgt zur gebührenden Ausfolgung mit der Beendigung; Urlaubsersatzleistungen und ausständige Beträge werden mit der Endabrechnung ausgewiesen.</p>
<p>Die Dienstnehmerin/der Dienstnehmer verpflichtet sich, sämtliche Betriebsgegenstände, Dokumente und Zugänge bis zum Ende des Dienstverhältnisses zurückzustellen.</p>
<p>Sämtliche gegenseitigen Ansprüche aus dem Dienstverhältnis — soweit gesetzlich zulässig und abweichend nicht geregelt — gelten mit diesem Tag als abgegolten.</p>
<p>Änderungen dieser Vereinbarung bedürfen der Schriftform.</p>
<p>Ort, Datum: {{ werte['ort_datum'] }}<br/><br/>Dienstgeber: ________________________<br/><br/>Dienstnehmer:in: ______________________</p>
</div>
]]></field>
<field name="feld_ids" eval="[
Command.create({'sequence': 1, 'key': 'seit', 'bezeichnung': 'Besteht seit (Dienstbeginn)', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 2, 'key': 'beendigungstermin', 'bezeichnung': 'Beendigungstermin', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 3, 'key': 'zeitguthaben', 'bezeichnung': 'Zeitguthaben (Stunden, leer = entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 3, 'key': 'zeitguthaben_zeit', 'bezeichnung': 'Zeitguthaben-Verbrauch (Zeitraum)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 4, 'key': 'urlaub_rest', 'bezeichnung': 'Resturlaub (Tage, leer = entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 4, 'key': 'urlaub_zeit', 'bezeichnung': 'Urlaubsverbrauch (Zeitraum)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 5, 'key': 'freistellung', 'bezeichnung': 'Bezahlte Freistellung (Zeitraum, leer = entfällt)', 'quelle': 'manuell', 'pflicht': False}),
Command.create({'sequence': 6, 'key': 'ort_datum', 'bezeichnung': 'Ort, Datum', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma', 'bezeichnung': 'Firmenname', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma_strasse', 'bezeichnung': 'Firma: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'firma_plz', 'bezeichnung': 'Firma: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'firma_ort', 'bezeichnung': 'Firma: Ort', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter', 'bezeichnung': 'Dienstnehmer:in (Name)', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_strasse', 'bezeichnung': 'Dienstnehmer:in: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_plz', 'bezeichnung': 'Dienstnehmer:in: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_ort', 'bezeichnung': 'Dienstnehmer:in: Ort', 'quelle': 'auto', 'pflicht': False}),
]"/>
</record>
<!-- ============ Vordienstzeitenbestätigung =================== -->
<record id="vorlage_vordienstzeiten" model="l10n.at.payroll.dokument.vorlage">
<field name="name">Vordienstzeitenbestätigung</field>
<field name="thema">bescheinigung</field>
<field name="kb_ref">lb-end-35</field>
<field name="quell_stand">2026-07</field>
<field name="rechtsstatus">entwurf</field>
<field name="beschreibung">
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).
</field>
<field name="inhalt_html"><![CDATA[
<div>
<p>{{ werte['firma'] }}, {{ werte['firma_strasse'] }}, {{ werte['firma_plz'] }} {{ werte['firma_ort'] }}</p>
<h2>Bestätigung über Vordienstzeiten</h2>
<p>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:</p>
<ul>
<li>{{ werte['abfertigung_art'] }}</li>
</ul>
<p>Die Bestätigung dient der Anrechnung von Vordienstzeiten für die Einstufung beim neuen Dienstgeber.</p>
<p>Datum: {{ werte['ort_datum'] }}</p>
<p>firmenmäßige Zeichnung: ________________________</p>
</div>
]]></field>
<field name="feld_ids" eval="[
Command.create({'sequence': 1, 'key': 'von', 'bezeichnung': 'Beschäftigt von', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 2, 'key': 'bis', 'bezeichnung': 'Beschäftigt bis', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 3, 'key': 'abfertigung_art', 'bezeichnung': 'Abfertigungsstatus (z. B. weder eine ... noch eine ... Abfertigung erhalten)', 'quelle': 'manuell', 'pflicht': True, 'hinweis': 'Text je Fall; Muster-Varianten: keine / gesetzliche / kollektivvertragliche / freiwillige Abfertigung samt Höhe'}),
Command.create({'sequence': 3, 'key': 'ort_datum', 'bezeichnung': 'Ort, Datum', 'quelle': 'manuell', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma', 'bezeichnung': 'Firmenname', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'firma_strasse', 'bezeichnung': 'Firma: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'firma_plz', 'bezeichnung': 'Firma: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'firma_ort', 'bezeichnung': 'Firma: Ort', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter', 'bezeichnung': 'Dienstnehmer:in (Name)', 'quelle': 'auto', 'pflicht': True}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_strasse', 'bezeichnung': 'Dienstnehmer:in: Straße', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_plz', 'bezeichnung': 'Dienstnehmer:in: PLZ', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'mitarbeiter_ort', 'bezeichnung': 'Dienstnehmer:in: Ort', 'quelle': 'auto', 'pflicht': False}),
Command.create({'sequence': 10, 'key': 'geboren', 'bezeichnung': 'Geburtsdatum', 'quelle': 'auto', 'pflicht': False}),
]"/>
</record>
</odoo>
@@ -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
@@ -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', '')),
}
@@ -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-<div> 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.'),
]
@@ -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()
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Generisches Drucklayout für Dokumente aus Vorlagen. -->
<!-- Aufgerufen vom Wizard (ir.actions.report._render_qweb_pdf); -->
<!-- kein Menü-Binding, da der Inhalt je Generierung variiert. -->
<record id="action_report_dokument_generisch" model="ir.actions.report">
<field name="name">Dokument aus Vorlage</field>
<field name="model">l10n.at.payroll.dokument.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">l10n_at_payroll_dokumente.report_dokument_generisch</field>
<field name="report_file">l10n_at_payroll_dokumente.report_dokument_generisch</field>
<field name="paperformat_id" ref="base.paperformat_euro"/>
</record>
<template id="report_dokument_generisch">
<t t-call="web.html_container">
<div class="page">
<h1 class="d-none"><t t-out="titel"/></h1>
<div t-out="body"/>
</div>
</t>
</template>
</odoo>
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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
3 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
4 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
5 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
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="rule_dokument_vorlage_company" model="ir.rule">
<field name="name">Dokumentvorlage: eigene Company</field>
<field name="model_id" ref="model_l10n_at_payroll_dokument_vorlage"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</odoo>
@@ -0,0 +1,2 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import test_dokumente
@@ -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':
'<p>Firma: {{ werte[\'firma\'] }}</p>'
'<p>Mitarbeiter: {{ werte[\'mitarbeiter\'] }}</p>'
'<p>Funktion: {{ werte[\'funktion\'] }}</p>'
'<p>EAD: {{ werte[\'ead\'] }}</p>'
'<p>Zusatz: {{ werte[\'zusatz\'] }}</p>',
'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': '<p>Body</p>'})
self.assertEqual(str(values['body']), '<p>Body</p>')
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)
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dokument_vorlage_list" model="ir.ui.view">
<field name="name">l10n.at.payroll.dokument.vorlage.list</field>
<field name="model">l10n.at.payroll.dokument.vorlage</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="thema"/>
<field name="kb_ref"/>
<field name="quell_stand"/>
<field name="rechtsstatus"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_dokument_vorlage_form" model="ir.ui.view">
<field name="name">l10n.at.payroll.dokument.vorlage.form</field>
<field name="model">l10n.at.payroll.dokument.vorlage</field>
<field name="arch" type="xml">
<form>
<header>
<field name="rechtsstatus" widget="statusbar"
options="{'clickable': true}"/>
</header>
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="z. B. Dienstvertrag (Angestellte)"/></h1>
</div>
<group>
<group>
<field name="thema"/>
<field name="active"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="kb_ref"/>
<field name="quell_stand"/>
<field name="rechtsstatus"/>
</group>
</group>
<group>
<field name="beschreibung"/>
</group>
<notebook>
<page string="Vorlagentext" name="text">
<field name="inhalt_html"/>
</page>
<page string="Befüllfelder" name="felder">
<field name="feld_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="key"/>
<field name="bezeichnung"/>
<field name="quelle"/>
<field name="pflicht"/>
<field name="hinweis"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="action_dokument_vorlage" model="ir.actions.act_window">
<field name="name">Dokumentvorlagen</field>
<field name="res_model">l10n.at.payroll.dokument.vorlage</field>
<field name="view_mode">list,form</field>
<field name="context">{'default_active': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Erste Dokumentvorlage anlegen
</p>
<p>
Eigener Text mit Platzhaltern {{ werte['key'] }}; die
Befüllfelder steuern, was der Generierungs-Wizard abfragt.
</p>
</field>
</record>
<menuitem id="menu_dokumente_root"
name="AT Dokumente"
sequence="50"/>
<menuitem id="menu_dokument_vorlage"
action="action_dokument_vorlage"
parent="menu_dokumente_root"
sequence="10"/>
</odoo>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_dokument_wizard_form" model="ir.ui.view">
<field name="name">l10n.at.payroll.dokument.wizard.form</field>
<field name="model">l10n.at.payroll.dokument.wizard</field>
<field name="arch" type="xml">
<form string="Dokument aus Vorlage generieren">
<group>
<group>
<field name="vorlage_id" options="{'no_create': True}"/>
<field name="employee_id"/>
<field name="datum"/>
<field name="version_id"/>
</group>
</group>
<notebook>
<page string="Befüllung" name="befuellung">
<field name="feld_wert_ids">
<list editable="bottom">
<field name="key" optional="show"/>
<field name="bezeichnung" optional="show"/>
<field name="hinweis" optional="hide"/>
<field name="quelle" optional="show"/>
<field name="pflicht" optional="hide"/>
<field name="wert"
readonly="key and quelle == 'auto'"
required="pflicht"/>
</list>
</field>
</page>
<page string="Vorschau" name="vorschau">
<field name="vorschau_html" nolabel="1"/>
</page>
</notebook>
<footer>
<button name="action_vorschau" type="object"
string="Vorschau erzeugen" class="btn-primary"/>
<button name="action_pdf" type="object"
string="PDF erzeugen &amp; ablegen"
class="btn-primary"/>
<button special="cancel" string="Abbrechen"/>
</footer>
</form>
</field>
</record>
<record id="action_dokument_generieren" model="ir.actions.act_window">
<field name="name">Dokument aus Vorlage generieren</field>
<field name="res_model">l10n.at.payroll.dokument.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<record id="action_dokument_generieren_employee" model="ir.actions.act_window">
<field name="name">Dokument aus Vorlage generieren</field>
<field name="res_model">l10n.at.payroll.dokument.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_employee_id': active_id}</field>
<field name="binding_model_id" ref="hr.model_hr_employee"/>
<field name="binding_view_types">list,form</field>
</record>
<menuitem id="menu_dokument_generieren"
action="action_dokument_generieren"
parent="menu_dokumente_root"
sequence="20"/>
</odoo>
+10 -2
View File
@@ -60,7 +60,9 @@ Absetzbeträgen — kein Pendlerpauschalen-Feature).
4. **Endabrechnungs-Zyklus**: Urlaubsersatzleistung (lb-url-09/15/16),
Abgangsentschädigung/Vergleich/Prämien bei Beendigung
(lb-end-04/05/06/08…22), Aufrechnung Vorschüsse/Darlehen
(lb-lvr-01/02), Zeitschulden/Zeitguthaben (lb-end-09/10)
(lb-lvr-01/02), Zeitschulden/Zeitguthaben (lb-end-09/10),
Ausbildungskostenrückersatz (lb-end-27/28 — **Nachtrag 2026-09-16**
aus Batch 10; nicht im Abgleich-Stand 2026-09-12)
5. **Abfertigung Alt**-Bezugszeile (lb-end-01/02, lb-kar-01)
6. **Karenz-/Familien-Reste**: SV-An/-Abmeldung Karenz, Elternteilzeit
(lb-elt-03), Bildungs-/Pflege-/Hospizkarenz (lb-kzs-01/02/06/07),
@@ -191,8 +193,14 @@ Test-Integration.
`l10n_at_s67_abs` ✅), Prämien bei Beendigung, Aufrechnung
Vorschüsse/Darlehen (Rückzahlungsmodus lb-end-06, lb-lvr-01/02),
Zeitguthaben/-schulden-Abrechnung.
- **Nachtrag (2026-09-16, Batch 10):** **Ausbildungskostenrückersatz**
bei vorzeitigem Austritt (lb-end-27 Checkliste, lb-end-28 Muster)
als Endabrechnungs-Komponente im Wizard — Rechtsgrundlage vor Bau
RIS-verifizieren (§ 32 AngG-Kontext/RL-VA); Scope-Gate Pilotmandant.
- **Rechtsanker:** lb-url-09/15/16, lb-son-05, lb-end-04/05/06/08…22;
§ 67 Abs 8 lit d EStG + § 11 Abs 2 ASVG RIS-Gate.
§ 67 Abs 8 lit d EStG + § 11 Abs 2 ASVG RIS-Gate; **lb-end-27/28**
(Ausbildungskostenrückersatz, Nachtrag 2026-09-16 — RIS-Gate vor
Bau).
- **Tests:** Rechenfälle `endabrechnung.md` — Urlaubsersatzleistung
2 Komponenten, §-11-Abs-2-SV-Fall, Vergleich Günstiger § 67 Abs 3,
Aufrechnung gegen SZ (pfändungsfest? — Schnittstelle AP11),