mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
[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
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user