diff --git a/addons/l10n_at_payroll_agent/__init__.py b/addons/l10n_at_payroll_agent/__init__.py new file mode 100644 index 0000000..aafb9f5 --- /dev/null +++ b/addons/l10n_at_payroll_agent/__init__.py @@ -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 \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/__manifest__.py b/addons/l10n_at_payroll_agent/__manifest__.py new file mode 100644 index 0000000..b53fde3 --- /dev/null +++ b/addons/l10n_at_payroll_agent/__manifest__.py @@ -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, +} \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/models/__init__.py b/addons/l10n_at_payroll_agent/models/__init__.py new file mode 100644 index 0000000..a5f9afb --- /dev/null +++ b/addons/l10n_at_payroll_agent/models/__init__.py @@ -0,0 +1,3 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import pv_agent_client +from . import pv_agent_context \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/models/pv_agent_client.py b/addons/l10n_at_payroll_agent/models/pv_agent_client.py new file mode 100644 index 0000000..43d8093 --- /dev/null +++ b/addons/l10n_at_payroll_agent/models/pv_agent_client.py @@ -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") \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/models/pv_agent_context.py b/addons/l10n_at_payroll_agent/models/pv_agent_context.py new file mode 100644 index 0000000..c90587d --- /dev/null +++ b/addons/l10n_at_payroll_agent/models/pv_agent_context.py @@ -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 + ) \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/security/ir.model.access.csv b/addons/l10n_at_payroll_agent/security/ir.model.access.csv new file mode 100644 index 0000000..6eb0a62 --- /dev/null +++ b/addons/l10n_at_payroll_agent/security/ir.model.access.csv @@ -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 \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/tests/__init__.py b/addons/l10n_at_payroll_agent/tests/__init__.py new file mode 100644 index 0000000..b599c92 --- /dev/null +++ b/addons/l10n_at_payroll_agent/tests/__init__.py @@ -0,0 +1,2 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import test_pv_agent_review \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/tests/test_pv_agent_review.py b/addons/l10n_at_payroll_agent/tests/test_pv_agent_review.py new file mode 100644 index 0000000..914d640 --- /dev/null +++ b/addons/l10n_at_payroll_agent/tests/test_pv_agent_review.py @@ -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) \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/views/pv_agent_review_views.xml b/addons/l10n_at_payroll_agent/views/pv_agent_review_views.xml new file mode 100644 index 0000000..f0e6ae1 --- /dev/null +++ b/addons/l10n_at_payroll_agent/views/pv_agent_review_views.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + res.company.form.pv.agent + res.company + + + + + + + + + + + + + pv.agent.review.wizard.form + pv.agent.review.wizard + +
+ + + + + + + + + + + + + + + + +
+
+
+
+
+ + + PV-Agent Prüfung + pv.agent.review.wizard + form + new + + + +
\ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/wizard/__init__.py b/addons/l10n_at_payroll_agent/wizard/__init__.py new file mode 100644 index 0000000..841a25e --- /dev/null +++ b/addons/l10n_at_payroll_agent/wizard/__init__.py @@ -0,0 +1,2 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +from . import pv_agent_review \ No newline at end of file diff --git a/addons/l10n_at_payroll_agent/wizard/pv_agent_review.py b/addons/l10n_at_payroll_agent/wizard/pv_agent_review.py new file mode 100644 index 0000000..46af713 --- /dev/null +++ b/addons/l10n_at_payroll_agent/wizard/pv_agent_review.py @@ -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) \ No newline at end of file