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_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
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
from . import pv_agent_client
|
||||
from . import pv_agent_context
|
||||
@@ -0,0 +1,123 @@
|
||||
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||
"""PV-Agent-Client: HTTP-Zugriff auf den eigenständigen Wissensdienst.
|
||||
|
||||
Odoo orchestriert und rechnet (System of Record, D25); der Agent antwortet
|
||||
streng KB-gebunden. Der Client sendet ausschließlich Frage, Modus und den
|
||||
schema-gebundenen Kontext — keine Personendaten, keine Rohobjekte. Der
|
||||
Service-Key liegt am Unternehmen (`pv_agent_api_key`) und wird niemals
|
||||
geloggt. HTTP-Zugriff über die stdlib (urllib) — keine Zusatzabhängigkeit.
|
||||
"""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
DEFAULT_PV_AGENT_URL = "http://127.0.0.1:8080"
|
||||
REQUEST_TIMEOUT_S = 240.0 # KB-Antworten beobachtet: ~40-160 s
|
||||
|
||||
|
||||
class PvAgentHttpError(RuntimeError):
|
||||
"""Interner Fehler-Wrapper für den Client-Test-Seam."""
|
||||
|
||||
def __init__(self, status, detail=""):
|
||||
super().__init__(f"HTTP {status}: {detail}")
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def _post_json(url, payload, api_key, request_id, timeout_s):
|
||||
"""Test-Seam: einziger HTTP-Ausgang des Moduls (stdlib-only)."""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": request_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = "Bearer " + api_key
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url, data=data, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout_s) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
pass
|
||||
raise PvAgentHttpError(exc.code, detail) from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise PvAgentHttpError(0, str(exc)) from exc
|
||||
|
||||
|
||||
def _error_detail(status, detail):
|
||||
"""Aus der Fehlerantwort des Agenten die neutrale Detailzeile ziehen —
|
||||
der Agent selbst liefert keine internen Hosts/Exceptions (v1-Vertrag)."""
|
||||
try:
|
||||
import json as _json
|
||||
body = _json.loads(detail)
|
||||
if isinstance(body, dict) and isinstance(body.get("detail"), str):
|
||||
return body["detail"]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
class PvAgentClient(models.AbstractModel):
|
||||
_name = "pv.agent.client"
|
||||
_description = "PV-Agent-Client (Wissensdienst / Review)"
|
||||
|
||||
def _l10n_at_pv_agent_url(self):
|
||||
return self.env.company.pv_agent_url or DEFAULT_PV_AGENT_URL
|
||||
|
||||
def _l10n_at_pv_agent_api_key(self):
|
||||
return self.env.company.pv_agent_api_key or ""
|
||||
|
||||
def l10n_at_pv_ask_review(self, question, context, request_id):
|
||||
"""POST /v1/ask (mode=review) — liefert die geprüfte Antwort des
|
||||
Agenten (dict). Fehler werden als UserError mit neutraler Meldung
|
||||
abgebildet; technische Details stehen nur im Server-Log."""
|
||||
url = self._l10n_at_pv_agent_url().rstrip("/") + "/v1/ask"
|
||||
payload = {
|
||||
"question": question,
|
||||
"mode": "review",
|
||||
"context": context,
|
||||
}
|
||||
try:
|
||||
status, body = _post_json(
|
||||
url, payload, self._l10n_at_pv_agent_api_key(), request_id,
|
||||
REQUEST_TIMEOUT_S)
|
||||
except PvAgentHttpError as exc:
|
||||
if exc.status == 401:
|
||||
raise UserError(_(
|
||||
"PV-Agent: Service-Key fehlt oder ist ungültig. "
|
||||
"PV-Agent-API-Key in den Unternehmensstammdaten prüfen."))
|
||||
if exc.status == 422:
|
||||
raise UserError(_(
|
||||
"PV-Agent hat die Anfrage abgelehnt: %s",
|
||||
_error_detail(exc.status, exc.detail) or "ungültige Anfrage"))
|
||||
if exc.status in (0, 502, 503, 504):
|
||||
raise UserError(_(
|
||||
"PV-Agent nicht erreichbar (URL: %s). Dienststatus und "
|
||||
"PV-Agent-URL in den Unternehmensstammdaten prüfen.",
|
||||
self._l10n_at_pv_agent_url()))
|
||||
raise UserError(_("PV-Agent-Fehler (HTTP %s).", exc.status)) from exc
|
||||
return body
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = "res.company"
|
||||
|
||||
pv_agent_url = fields.Char(
|
||||
string="PV-Agent-URL",
|
||||
default=DEFAULT_PV_AGENT_URL,
|
||||
help="Basis-URL des eigenständigen Wissensdienstes, z. B. "
|
||||
"http://127.0.0.1:8080 wenn der Agent am selben Host läuft.",
|
||||
groups="hr_payroll.group_hr_payroll_user")
|
||||
pv_agent_api_key = fields.Char(
|
||||
string="PV-Agent Service-Key",
|
||||
help="Bearer-Key der Agent-API. Nur im Unternehmen hinterlegen — "
|
||||
"niemals in Logs oder Meldungen ausgeben.",
|
||||
groups="hr_payroll.group_hr_payroll_user")
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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
|
||||
)
|
||||
Reference in New Issue
Block a user