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,4 @@
|
|||||||
|
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||||
|
"""PV-Agent-Brücke: Odoo orchestriert, der Agent prüft (D25/M4.1)."""
|
||||||
|
from . import models
|
||||||
|
from . import wizard
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||||
|
{
|
||||||
|
'name': 'Österreich - PV-Agent Prüfung (AT Payroll Agent)',
|
||||||
|
'version': '19.0.1.0.0',
|
||||||
|
'author': 'Florian Egger',
|
||||||
|
'website': 'https://gem360.at',
|
||||||
|
'category': 'Human Resources/Payroll',
|
||||||
|
'description': """
|
||||||
|
PV-Agent-Brücke (D25/M4.1)
|
||||||
|
==========================
|
||||||
|
Odoo orchestriert und rechnet (System of Record); der eigenständige,
|
||||||
|
KB-gebundene Wissensdienst (pv-agent) prüft Plausibilität und liefert
|
||||||
|
belegte Antworten. Kein Rückpfad: Der Agent ruft niemals Odoo auf und
|
||||||
|
erhält keine Personendaten — nur das minimierte, schema-gebundene
|
||||||
|
Kontextformat der Agent-API v1 (mode=review).
|
||||||
|
|
||||||
|
- **Pilot-Workflow „Geplante Auszahlung prüfen“:** Wizard am gerechneten
|
||||||
|
Draft-Lohnzettel mit ATP_PRAMIE-Eingabe. Der Kontext-Builder projiziert
|
||||||
|
Bruttolohn (hr.version.wage), anwendenden KV (version.kv_id) und die
|
||||||
|
DG-/LST-Zeilen des Drafts (SVDG_*/LST_*/FLAFDB/KOMMST/DZ/BVG/WIEN_DAG)
|
||||||
|
auf das Review-Kontextformat (facts + computation).
|
||||||
|
- **Client (stdlib urllib, kein Zusatz-Dependency):** POST /v1/ask mit
|
||||||
|
X-Request-ID, Bearer-Key aus den Unternehmensstammdaten
|
||||||
|
(res.company.pv_agent_url / pv_agent_api_key, group_hr_payroll_user);
|
||||||
|
neutrale UserError-Meldungen, technische Details nur im Server-Log.
|
||||||
|
- **Antwortdarstellung:** Agent-Antwort (belegt), strukturiertes
|
||||||
|
Verdict (plausible / implausible / nicht prüfbar) und Prüfpunkte im
|
||||||
|
Dialog. Odoo bleibt autoritativ für Zahlen; der Agent korrigiert nichts
|
||||||
|
stillschweigend (D25).
|
||||||
|
- Privacy: Der Agentendienst läuft lokal (Tailscale-Host); die
|
||||||
|
Datenschutzerarbeitung bleibt in Odoo. Der Wizard ist an
|
||||||
|
hr_payroll.group_hr_payroll_user gebunden.
|
||||||
|
""",
|
||||||
|
'depends': ['l10n_at_hr_payroll_private'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/pv_agent_review_views.xml',
|
||||||
|
],
|
||||||
|
'countries': ['at'],
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_pv_agent_review_wizard,PV Agent Review Wizard,model_pv_agent_review_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||||
|
from . import test_pv_agent_review
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||||
|
"""Tests: PV-Agent-Client (Konfiguration, Payload, Fehlerabbildung) und
|
||||||
|
Check-Rendering. Läuft nur in einer Odoo-Test-Session
|
||||||
|
(`odoo-bin -i l10n_at_payroll_agent --test-tags ...`); die Builder-Tests am
|
||||||
|
echten Lohnzettel folgen mit der Pilot-Workflow-Verifikation."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
from odoo.addons.l10n_at_payroll_agent.models.pv_agent_client import (
|
||||||
|
DEFAULT_PV_AGENT_URL,
|
||||||
|
_post_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class PvAgentClientTest(TransactionCase):
|
||||||
|
"""Client-Konfiguration (res.company), Payload-Vertrag und
|
||||||
|
Fehlerabbildung (neutral, ohne interne Details)."""
|
||||||
|
|
||||||
|
def test_01_params_default(self):
|
||||||
|
client = self.env["pv.agent.client"]
|
||||||
|
self.assertEqual(client._l10n_at_pv_agent_url(), DEFAULT_PV_AGENT_URL)
|
||||||
|
self.assertEqual(client._l10n_at_pv_agent_api_key(), "")
|
||||||
|
|
||||||
|
def test_02_params_company_override(self):
|
||||||
|
self.env.company.pv_agent_url = "http://127.0.0.1:8080"
|
||||||
|
self.env.company.pv_agent_api_key = "secret-key"
|
||||||
|
client = self.env["pv.agent.client"]
|
||||||
|
self.assertEqual(client._l10n_at_pv_agent_url(), "http://127.0.0.1:8080")
|
||||||
|
self.assertEqual(client._l10n_at_pv_agent_api_key(), "secret-key")
|
||||||
|
|
||||||
|
def test_03_ask_review_payload(self):
|
||||||
|
"""mode=review, schema-gebundener Kontext und Request-ID werden
|
||||||
|
als /v1/ask-Payload gesendet; die Antwort wird unverändert
|
||||||
|
zurückgegeben."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_post(url, payload, api_key, request_id, timeout_s):
|
||||||
|
captured.update({
|
||||||
|
"url": url,
|
||||||
|
"payload": payload,
|
||||||
|
"api_key": api_key,
|
||||||
|
"request_id": request_id,
|
||||||
|
})
|
||||||
|
return 200, {
|
||||||
|
"answer": "Antwort [lb-min-01].",
|
||||||
|
"verified": True,
|
||||||
|
"request_id": request_id,
|
||||||
|
"plausibility": {"verdict": "plausible", "checks": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.env.company.pv_agent_api_key = "secret-key"
|
||||||
|
with patch(
|
||||||
|
"odoo.addons.l10n_at_payroll_agent.models.pv_agent_client._post_json",
|
||||||
|
side_effect=fake_post,
|
||||||
|
):
|
||||||
|
body = self.env["pv.agent.client"].l10n_at_pv_ask_review(
|
||||||
|
"Prüfe die Auszahlung.",
|
||||||
|
{"facts": [{"key": "zahlung", "value": "500 EUR"}],
|
||||||
|
"computation": None,
|
||||||
|
"note": None},
|
||||||
|
"odoo-abc123",
|
||||||
|
)
|
||||||
|
self.assertEqual(captured["url"], "http://127.0.0.1:8080/v1/ask")
|
||||||
|
self.assertEqual(captured["payload"]["mode"], "review")
|
||||||
|
self.assertEqual(captured["payload"]["question"], "Prüfe die Auszahlung.")
|
||||||
|
self.assertEqual(captured["api_key"], "secret-key")
|
||||||
|
self.assertEqual(captured["request_id"], "odoo-abc123")
|
||||||
|
self.assertEqual(body["plausibility"]["verdict"], "plausible")
|
||||||
|
|
||||||
|
def test_04_http_401_maps_to_user_error(self):
|
||||||
|
from odoo.addons.l10n_at_payroll_agent.models.pv_agent_client import (
|
||||||
|
PvAgentHttpError,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"odoo.addons.l10n_at_payroll_agent.models.pv_agent_client._post_json",
|
||||||
|
side_effect=PvAgentHttpError(401, '{"detail": "Authentisierung"}'),
|
||||||
|
):
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.env["pv.agent.client"].l10n_at_pv_ask_review(
|
||||||
|
"Frage", {"facts": []}, "odoo-abc123")
|
||||||
|
|
||||||
|
def test_05_unreachable_maps_to_user_error(self):
|
||||||
|
from odoo.addons.l10n_at_payroll_agent.models.pv_agent_client import (
|
||||||
|
PvAgentHttpError,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"odoo.addons.l10n_at_payroll_agent.models.pv_agent_client._post_json",
|
||||||
|
side_effect=PvAgentHttpError(503, ""),
|
||||||
|
):
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.env["pv.agent.client"].l10n_at_pv_ask_review(
|
||||||
|
"Frage", {"facts": []}, "odoo-abc123")
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class PvAgentChecksRenderingTest(TransactionCase):
|
||||||
|
"""Verdict-Rendering für den Dialog (deterministisch, ohne HTTP)."""
|
||||||
|
|
||||||
|
def test_06_render_checks(self):
|
||||||
|
wizard = self.env["pv.agent.review.wizard"]
|
||||||
|
checks_text = wizard._l10n_at_pv_render_checks({
|
||||||
|
"checks": [
|
||||||
|
{"status": "warn", "aspect": "Lohnsteuer",
|
||||||
|
"detail": "erwartet 30 EUR — erhalten 0 EUR",
|
||||||
|
"source_ids": ["lb-lvr-07"]},
|
||||||
|
{"status": "open", "aspect": "DZ",
|
||||||
|
"detail": "Gemeinde fehlt", "source_ids": []},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.assertIn("[WARN ⚠] Lohnsteuer", checks_text)
|
||||||
|
self.assertIn("(lb-lvr-07)", checks_text)
|
||||||
|
self.assertIn("[OFFEN] DZ", checks_text)
|
||||||
|
self.assertNotIn("()", checks_text)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- D25/M4.1: PV-Agent-Review-Wizard. Die Company-Felder (URL/ -->
|
||||||
|
<!-- Service-Key) werden im Company-Formular gepflegt und sind auf -->
|
||||||
|
<!-- group_hr_payroll_user eingeschränkt. Der Agent-Service ist -->
|
||||||
|
<!-- eigenständig und bleibt autoritativ für die Wissensbasis; -->
|
||||||
|
<!-- Odoo bleibt autoritativ für die Zahlen (kein Rückpfad). -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
|
||||||
|
<record id="res_company_view_form_pv_agent" model="ir.ui.view">
|
||||||
|
<field name="name">res.company.form.pv.agent</field>
|
||||||
|
<field name="model">res.company</field>
|
||||||
|
<field name="inherit_id" ref="base.view_company_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//page[@name='l10n_at_payroll']" position="inside">
|
||||||
|
<group string="PV-Agent (Wissensdienst)"
|
||||||
|
name="l10n_at_pv_agent"
|
||||||
|
groups="hr_payroll.group_hr_payroll_user">
|
||||||
|
<field name="pv_agent_url" placeholder="http://127.0.0.1:8080"/>
|
||||||
|
<field name="pv_agent_api_key" password="True"/>
|
||||||
|
</group>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="pv_agent_review_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">pv.agent.review.wizard.form</field>
|
||||||
|
<field name="model">pv.agent.review.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="PV-Agent: Plausibilitätsprüfung">
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="payslip_id"/>
|
||||||
|
<field name="question"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="request_id" invisible="not request_id"/>
|
||||||
|
<field name="verdict" invisible="not verdict"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Agent-Antwort (belegt)" invisible="not answer">
|
||||||
|
<field name="answer" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
<group string="Prüfpunkte (Agent, KB-belegt)" invisible="not checks">
|
||||||
|
<field name="checks" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button string="Prüfung starten" class="btn-primary"
|
||||||
|
name="action_l10n_at_pv_review" type="object"
|
||||||
|
icon="fa-search"/>
|
||||||
|
<button string="Abbrechen" class="btn-secondary"
|
||||||
|
special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_pv_agent_review_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">PV-Agent Prüfung</field>
|
||||||
|
<field name="res_model">pv.agent.review.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_l10n_at_payroll_agent"
|
||||||
|
name="PV-Agent Prüfung"
|
||||||
|
parent="l10n_at_hr_payroll_private.menu_l10n_at_payroll_private_root"
|
||||||
|
action="action_pv_agent_review_wizard" sequence="50"/>
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||||
|
from . import pv_agent_review
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Part of the odoo-at-payroll project. License: LGPL-3.
|
||||||
|
"""PV-Agent-Review-Wizard (D25/M4.1 Pilot): Plausibilitätsprüfung einer
|
||||||
|
geplanten einmaligen Auszahlung.
|
||||||
|
|
||||||
|
Workflow: Draft-Lohnzettel mit ATP_PRAMIE-Eingabe rechnen → Wizard öffnen →
|
||||||
|
„Prüfung starten“ → Odoo baut den minimierten Kontext und ruft den Agenten
|
||||||
|
(mode=review) → Verdict + belegte Antwort werden im Dialog angezeigt. Odoo
|
||||||
|
bleibt autoritativ für Zahlen; der Agent korrigiert nichts stillschweigend.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from odoo import _, fields, models
|
||||||
|
|
||||||
|
REVIEW_QUESTION_DEFAULT = (
|
||||||
|
"Prüfe die geplante Auszahlung gegen die Regeln der Wissensbasis."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PvAgentReviewWizard(models.TransientModel):
|
||||||
|
_name = "pv.agent.review.wizard"
|
||||||
|
_description = "PV-Agent: Plausibilitätsprüfung"
|
||||||
|
_order = "id desc"
|
||||||
|
|
||||||
|
payslip_id = fields.Many2one(
|
||||||
|
"hr.payslip", string="Lohnzettel (Draft)", required=True,
|
||||||
|
domain="[('state', '=', 'draft')]",
|
||||||
|
help="Gerechneter Draft-Lohnzettel mit einer ATP_PRAMIE-Eingabe "
|
||||||
|
"(geplante Auszahlung).")
|
||||||
|
question = fields.Text(
|
||||||
|
string="Frage an den Agenten", required=True,
|
||||||
|
default=REVIEW_QUESTION_DEFAULT)
|
||||||
|
answer = fields.Text(string="Agent-Antwort (belegt)", readonly=True)
|
||||||
|
verdict = fields.Selection(
|
||||||
|
[("plausible", "Plausibel"),
|
||||||
|
("implausible", "Nicht plausibel"),
|
||||||
|
("not_checkable", "Nicht prüfbar")],
|
||||||
|
string="Verdict", readonly=True)
|
||||||
|
checks = fields.Text(string="Prüfpunkte", readonly=True)
|
||||||
|
request_id = fields.Char(string="Request-ID", readonly=True)
|
||||||
|
|
||||||
|
def action_l10n_at_pv_review(self):
|
||||||
|
self.ensure_one()
|
||||||
|
context = self.env["pv.agent.context.builder"].l10n_at_pv_build_payout_context(
|
||||||
|
self.payslip_id)
|
||||||
|
request_id = "odoo-" + uuid.uuid4().hex[:12]
|
||||||
|
body = self.env["pv.agent.client"].l10n_at_pv_ask_review(
|
||||||
|
self.question, context, request_id)
|
||||||
|
plausibility = body.get("plausibility") or {}
|
||||||
|
self.write({
|
||||||
|
"answer": body.get("answer", ""),
|
||||||
|
"verdict": plausibility.get("verdict") or "not_checkable",
|
||||||
|
"checks": self._l10n_at_pv_render_checks(plausibility),
|
||||||
|
"request_id": body.get("request_id") or request_id,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": self._name,
|
||||||
|
"res_id": self.id,
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "new",
|
||||||
|
"name": _("PV-Agent Prüfung"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _l10n_at_pv_render_checks(plausibility):
|
||||||
|
lines = []
|
||||||
|
for check in plausibility.get("checks", []):
|
||||||
|
label = {"ok": "OK", "warn": "WARN ⚠", "open": "OFFEN"}.get(
|
||||||
|
check.get("status"), check.get("status", ""))
|
||||||
|
ids = ", ".join(check.get("source_ids", []))
|
||||||
|
lines.append(
|
||||||
|
f"[{label}] {check.get('aspect', '')} — {check.get('detail', '')}"
|
||||||
|
+ (f" ({ids})" if ids else ""))
|
||||||
|
return "\n".join(lines)
|
||||||
Reference in New Issue
Block a user