[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:
2026-09-17 13:50:18 +02:00
parent ec33dfacf2
commit a848c07e96
11 changed files with 558 additions and 0 deletions
@@ -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)