Files
odoo-at-payroll/addons/l10n_at_payroll_agent/models/pv_agent_context.py
T
fegger a848c07e96 [ADD] l10n_at_payroll_agent: PV-agent review bridge for payout plausibility
PV-agent bridge (D25/M4.1): Odoo stays the system of record; the
standalone, KB-bound knowledge service (pv-agent) reviews plausibility
and returns evidence-backed answers (mode=review, agent API v1). No
back path: the agent never calls Odoo and receives no personal data —
only the minimized, schema-bound review context.

- context builder: projects a computed draft payslip with an
  ATP_PRAMIE input (planned one-off payout) onto the review context
  (facts + computation): gross wage (hr.version.wage), applying
  collective agreement (version.kv_id) and the draft's DG/LST lines
  (SVDG_*/LST_*/FLAFDB/KOMMST/DZ/BVG/WIEN_DAG)
- client (stdlib urllib, no extra dependency): POST /v1/ask with
  X-Request-ID, bearer key from company settings (pv_agent_url /
  pv_agent_api_key, group_hr_payroll_user); neutral UserErrors,
  technical details only in the server log
- review wizard on the draft payslip: agent answer (evidence-backed),
  structured verdict (plausible / implausible / not checkable) and
  checks in the dialog
- security: wizard bound to hr_payroll.group_hr_payroll_user; agent
  service key stored on res.company, never logged; 5 tests
2026-09-17 13:50:18 +02:00

117 lines
4.5 KiB
Python

# Part of the odoo-at-payroll project. License: LGPL-3.
"""Kontext-Builder für den PV-Agent-Review (D25/M4.1).
Odoo ist System of Record: Der Builder projiziert den gerechneten
Draft-Lohnzettel auf das schema-gebundene Review-Kontextformat der
Agent-API v1 (facts + computation, alles als Strings). Datensparsamkeit
per Whitelist: keine Namen, keine SV-/Personalnummern — nur die für die
Abgabenprüfung relevanten Ist-Werte und das Odoo-Ergebnis.
Verifizierte Felder/APIs (Bestand):
- payslip.version_id / version.wage (versichertenmeldung.py:274)
- version.kv_id (l10n_at_hr_payroll_private/models/hr_version_private.py)
- payslip.input_line_ids / input.input_type_id.code (reisekosten.py:215)
- payslip.line_ids mit l.code / l.total (lohnsteuer.py:266-268)
- Rule-Codes: SVDG_*, LST_* (salary_rules_*.xml) und FLAFDB/KOMMST/DZ/BVG/
WIEN_DAG; Input-Code ATP_PRAMIE (input_types_private.xml).
"""
from odoo import _, models
from odoo.exceptions import UserError
PRAMIE_INPUT_CODE = "ATP_PRAMIE"
# DG-/LST-relevante Regel-Codes (verifiziert aus den Salary-Rules-XMLs):
# SVDG_* (SV-Dienstgeberanteil), LST_* (Lohnsteuer), FLAFDB (§ 41 FLAG),
# KOMMST (§ 9 KommStG), DZ (§§ 122/126 WKG), BVG (§ 6 BMSVG),
# WIEN_DAG (§ 1 DAG).
DG_RULE_PREFIXES = ("SVDG", "LST")
DG_RULE_EXACT = ("FLAFDB", "KOMMST", "DZ", "BVG", "WIEN_DAG")
def _eur(value):
return f"{value:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") + " EUR"
class PvAgentContextBuilder(models.AbstractModel):
_name = "pv.agent.context.builder"
_description = "PV-Agent Kontext-Builder (Review)"
def l10n_at_pv_build_payout_context(self, payslip):
"""Projiziert einen gerechneten Draft-Lohnzettel mit einer
ATP_PRAMIE-Eingabe (geplante einmalige Auszahlung) auf den
Review-Kontext. Rechnet nichts neu — Odoo bleibt autoritativ."""
payslip.ensure_one()
if payslip.state != "draft":
raise UserError(_(
"Plausibilitätsprüfung nur für Draft-Lohnzettel möglich."))
pramie = payslip.input_line_ids.filtered(
lambda line: line.input_type_id.code == PRAMIE_INPUT_CODE)
if not pramie:
raise UserError(_(
"Keine Eingabe „Prämie/Ausgleich (ATP_PRAMIE)“ am Draft "
"angetroffen. Die geplante Auszahlung zuerst als Eingabe "
"erfassen und den Lohnzettel neu rechnen."))
pramie_total = sum(pramie.mapped("amount"))
version = payslip.version_id
facts = [
{
"key": "bruttolohn_monat",
"value": _eur(version.wage),
},
{
"key": "zahlung",
"value": (
f"einmalig {_eur(pramie_total)} als Barzahlung "
f"(Eingabe {PRAMIE_INPUT_CODE}, Zeitraum "
f"{payslip.date_from} bis {payslip.date_to})"
),
},
]
if version.kv_id:
facts.append({
"key": "anwendender_kv",
"value": (
f"{version.kv_id.name}"
+ (f" ({version.kv_id.code})" if version.kv_id.code else "")
),
})
lines = payslip.line_ids.filtered(self._l10n_at_pv_is_dg_line)
if not lines:
raise UserError(_(
"Der Draft-Lohnzettel enthält keine gerechneten "
"Dienstgeber-/Lohnsteuer-Zeilen. Erst „Lohnzettel rechnen“ "
"ausführen."))
components = [
{
"key": line.code.lower(),
"value": _eur(line.total),
"note": line.name or "",
}
for line in lines.sorted(key=lambda l: l.code)
]
dg_gesamt = sum(
line.total for line in lines if line.code.startswith("SVDG")
)
computation = {
"label": "AG-Belastung der geplanten Auszahlung (Odoo-Draft)",
"result": _eur(dg_gesamt),
"basis": (
"gerechneter Draft-Lohnzettel, DG-/LST-Regecodes "
"SVDG_*/LST_*/FLAFDB/KOMMST/DZ/BVG/WIEN_DAG"
),
"components": components,
}
return {
"facts": facts,
"computation": computation,
"note": "Simulationslauf auf Draft-Lohnzettel; keine Buchung.",
}
@staticmethod
def _l10n_at_pv_is_dg_line(line):
code = line.code or ""
return (
code.startswith(DG_RULE_PREFIXES) or code in DG_RULE_EXACT
)