-- ============================================================================= -- DC_BACKEND_PKG -- PL/SQL-Schnittstelle zum Quarkus dc-backend (Kubernetes Service dc-backend:8090) -- -- Voraussetzungen: -- - APEX_WEB_SERVICE-Berechtigung für das Schema: -- GRANT EXECUTE ON APEX_240100.APEX_WEB_SERVICE TO ; -- APEX-Version anpassen -- - ACL für ausgehende HTTP-Verbindung im selben Namespace: -- DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE( -- host => 'dc-backend', lower_port => 8090, upper_port => 8090, -- ace => xs$ace_type(privilege_list => xs$name_list('connect','resolve'), -- principal_name => '', principal_type => xs_acl.ptype_db)); -- -- Deployment: -- sqlplus user/pass@db @"Scripts/DC_BACKEND_PKG.sql" -- ============================================================================= -- ============================================================================= -- Package Spec -- ============================================================================= CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS -- Basis-URL des dc-backend Kubernetes Service c_base_url CONSTANT VARCHAR2(200) := 'http://dc-backend:8090'; -- API-Key (X-API-KEY Header). -- Leer = kein Auth (Dev-Modus). -- Zur Laufzeit setzen: DC_BACKEND_PKG.g_api_key := 'mein-key'; g_api_key VARCHAR2(500) := ''; -- ------------------------------------------------------------------------- -- Startet die KI-gestützte Dokumentenprüfung für ein Projekt (asynchron). -- Das Projekt muss im Status PENDING sein. -- POST /check/{p_project_id} → 202 Accepted -- -- p_project_id ID des dc_projects-Eintrags -- p_status_code HTTP-Statuscode der Antwort (202 = OK, 409 = nicht PENDING) -- p_response JSON-Antwort des Backend -- ------------------------------------------------------------------------- PROCEDURE start_check( p_project_id IN NUMBER, p_status_code OUT NUMBER, p_response OUT VARCHAR2 ); -- ------------------------------------------------------------------------- -- Health-Check: prüft ob der dc-backend Service erreichbar ist. -- GET /check/health → 200 UP -- -- Rückgabe: TRUE = Service läuft, FALSE = nicht erreichbar -- ------------------------------------------------------------------------- FUNCTION health_check RETURN BOOLEAN; -- ------------------------------------------------------------------------- -- Startet die Prüfung und wirft eine Exception wenn nicht 202 zurückkommt. -- Convenience-Wrapper für direkte APEX-Button-Aufrufe. -- ------------------------------------------------------------------------- PROCEDURE start_check_or_raise( p_project_id IN NUMBER ); -- ------------------------------------------------------------------------- -- Fortschritts-Record -- ------------------------------------------------------------------------- TYPE t_project_status IS RECORD ( status dc_projects.status%TYPE, processing_progress dc_projects.progress%TYPE, is_completed BOOLEAN, is_running BOOLEAN, is_pending BOOLEAN ); -- ------------------------------------------------------------------------- -- Liest Status und Fortschritt eines Projekts direkt aus der DB. -- Kein REST-Aufruf – sofort und transaktionssicher. -- -- Beispiel: -- l_s := DC_BACKEND_PKG.get_status(1); -- DBMS_OUTPUT.PUT_LINE(l_s.status || ' ' || l_s.processing_progress || '%'); -- ------------------------------------------------------------------------- FUNCTION get_status( p_project_id IN NUMBER ) RETURN t_project_status; -- ------------------------------------------------------------------------- -- Wartet (polling) bis das Projekt COMPLETED oder ein Timeout erreicht ist. -- Wirft eine Exception bei Timeout oder wenn das Projekt nicht gefunden wird. -- -- p_timeout_sec Max. Wartezeit in Sekunden (Default: 1800 = 30 Min.) -- p_interval_sec Polling-Intervall in Sekunden (Default: 5) -- ------------------------------------------------------------------------- PROCEDURE wait_for_completion( p_project_id IN NUMBER, p_timeout_sec IN NUMBER DEFAULT 1800, p_interval_sec IN NUMBER DEFAULT 5 ); -- ------------------------------------------------------------------------- -- Konvertiert einen Markdown-CLOB zu DOCX oder PDF. -- POST /export/markdown → BLOB (application/pdf oder .docx) -- -- p_markdown Markdown-Text (z.B. dc_projects.report_markdown) -- p_format 'PDF' (Standard) oder 'DOCX' -- Rückgabe BLOB mit dem fertigen Dokument -- ------------------------------------------------------------------------- FUNCTION convert_markdown( p_markdown IN CLOB, p_format IN VARCHAR2 DEFAULT 'PDF' ) RETURN BLOB; -- ------------------------------------------------------------------------- -- Convenience: liest report_markdown des Projekts und konvertiert zu PDF/DOCX. -- -- Beispiel: -- v_blob := DC_BACKEND_PKG.project_report(1, 'PDF'); -- ------------------------------------------------------------------------- FUNCTION project_report( p_project_id IN NUMBER, p_format IN VARCHAR2 DEFAULT 'PDF' ) RETURN BLOB; -- ------------------------------------------------------------------------- -- Generiert den Markdown-Prüfbericht für ein Projekt neu und speichert ihn -- in dc_projects.report_markdown. Ruft intern DC_UTILS_PKG.generate_report auf. -- -- Beispiel: -- DC_BACKEND_PKG.regenerate_report(1); -- ------------------------------------------------------------------------- PROCEDURE regenerate_report( p_project_id IN NUMBER ); -- ------------------------------------------------------------------------- -- Startet die asynchrone Katalog-Generierung aus einem Regelwerk-Dokument. -- Gibt sofort zurück (202 Accepted-Muster) – die KI läuft im Hintergrund. -- -- p_file BLOB des Regelwerks (PDF, DOCX, ODT, TXT) -- p_mime_type MIME-Typ, z.B. 'application/pdf' -- p_filename Dateiname (wird im multipart-Part übergeben) -- p_catalog_name Name des anzulegenden Katalogs (z.B. 'ADSp 2017') -- p_document_type_id ID des Dokumenttyps (dc_document_types.id) -- p_description Optionale Beschreibung -- Rückgabe ID des Katalog-Placeholders (Status: GENERATING) -- -- Beispiel (mit Warten auf Abschluss): -- DECLARE -- l_blob BLOB; -- l_id NUMBER; -- BEGIN -- SELECT file_blob INTO l_blob FROM dc_reference_documents WHERE id = 1; -- l_id := DC_BACKEND_PKG.generate_catalog( -- p_file => l_blob, -- p_mime_type => 'application/pdf', -- p_filename => 'ADSp-2017.pdf', -- p_catalog_name => 'ADSp 2017', -- p_document_type_id => 1, -- p_description => 'Allgemeine Deutsche Spediteurbedingungen 2017'); -- DBMS_OUTPUT.PUT_LINE('Katalog ID: ' || l_id || ' (läuft im Hintergrund)'); -- DC_BACKEND_PKG.wait_for_catalog(l_id); -- optional, wartet bis READY -- DBMS_OUTPUT.PUT_LINE('Katalog fertig!'); -- END; -- ------------------------------------------------------------------------- FUNCTION generate_catalog( p_file IN BLOB, p_mime_type IN VARCHAR2 DEFAULT 'application/pdf', p_filename IN VARCHAR2 DEFAULT 'dokument.pdf', p_catalog_name IN VARCHAR2, p_document_type_id IN NUMBER, p_description IN VARCHAR2 DEFAULT NULL ) RETURN NUMBER; -- ------------------------------------------------------------------------- -- Liest den Generierungsstatus direkt aus der DB (kein REST-Aufruf). -- Rückgabe: 'GENERATING', 'READY' oder 'ERROR' -- ------------------------------------------------------------------------- FUNCTION get_catalog_status( p_catalog_id IN NUMBER ) RETURN VARCHAR2; -- ------------------------------------------------------------------------- -- Wartet (polling) bis der Katalog den Status READY erreicht hat. -- Wirft eine Exception bei Timeout oder wenn der Katalog ERROR meldet. -- -- p_timeout_sec Max. Wartezeit in Sekunden (Default: 1800 = 30 Min.) -- p_interval_sec Polling-Intervall in Sekunden (Default: 10) -- ------------------------------------------------------------------------- PROCEDURE wait_for_catalog( p_catalog_id IN NUMBER, p_timeout_sec IN NUMBER DEFAULT 1800, p_interval_sec IN NUMBER DEFAULT 10 ); END DC_BACKEND_PKG; / -- ============================================================================= -- Package Body -- ============================================================================= CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS -- ------------------------------------------------------------------------- -- Setzt gemeinsame Request-Header (Content-Type + optional API-Key) -- ------------------------------------------------------------------------- PROCEDURE set_headers IS l_idx PLS_INTEGER := 1; BEGIN APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE; APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).NAME := 'Content-Type'; APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).VALUE := 'application/json'; l_idx := l_idx + 1; IF g_api_key IS NOT NULL AND LENGTH(TRIM(g_api_key)) > 0 THEN APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).NAME := 'X-API-KEY'; APEX_WEB_SERVICE.G_REQUEST_HEADERS(l_idx).VALUE := g_api_key; END IF; END set_headers; -- ------------------------------------------------------------------------- PROCEDURE start_check( p_project_id IN NUMBER, p_status_code OUT NUMBER, p_response OUT VARCHAR2 ) IS l_url VARCHAR2(500); l_response CLOB; BEGIN l_url := c_base_url || '/check/' || TO_CHAR(p_project_id); set_headers(); l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST( p_url => l_url, p_http_method => 'POST', p_body => '{}' -- leerer Body, projectId ist im Pfad ); p_status_code := APEX_WEB_SERVICE.G_STATUS_CODE; p_response := SUBSTR(l_response, 1, 4000); EXCEPTION WHEN OTHERS THEN p_status_code := -1; p_response := 'Verbindungsfehler: ' || SQLERRM; END start_check; -- ------------------------------------------------------------------------- FUNCTION health_check RETURN BOOLEAN IS l_url VARCHAR2(500); l_response CLOB; l_status NUMBER; BEGIN l_url := c_base_url || '/check/health'; set_headers(); l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST( p_url => l_url, p_http_method => 'GET' ); l_status := APEX_WEB_SERVICE.G_STATUS_CODE; RETURN l_status = 200; EXCEPTION WHEN OTHERS THEN RETURN FALSE; END health_check; -- ------------------------------------------------------------------------- PROCEDURE start_check_or_raise( p_project_id IN NUMBER ) IS l_status NUMBER; l_response VARCHAR2(4000); BEGIN start_check( p_project_id => p_project_id, p_status_code => l_status, p_response => l_response ); IF l_status = 202 THEN -- OK: Verarbeitung wurde gestartet NULL; ELSIF l_status = 409 THEN RAISE_APPLICATION_ERROR(-20100, 'Projekt ' || p_project_id || ' ist nicht im Status PENDING.'); ELSIF l_status = 401 THEN RAISE_APPLICATION_ERROR(-20101, 'Ungültiger API-Key für dc-backend.'); ELSIF l_status = -1 THEN RAISE_APPLICATION_ERROR(-20102, 'dc-backend nicht erreichbar: ' || l_response); ELSE RAISE_APPLICATION_ERROR(-20103, 'Unerwarteter HTTP-Status ' || l_status || ': ' || l_response); END IF; END start_check_or_raise; -- ------------------------------------------------------------------------- FUNCTION get_status( p_project_id IN NUMBER ) RETURN t_project_status IS l_rec t_project_status; BEGIN SELECT status, progress INTO l_rec.status, l_rec.processing_progress FROM dc_projects WHERE id = p_project_id; l_rec.is_completed := l_rec.status = 'COMPLETED'; l_rec.is_running := l_rec.status = 'IN_PROGRESS'; l_rec.is_pending := l_rec.status = 'PENDING'; RETURN l_rec; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20110, 'Projekt ' || p_project_id || ' nicht gefunden.'); END get_status; -- ------------------------------------------------------------------------- PROCEDURE wait_for_completion( p_project_id IN NUMBER, p_timeout_sec IN NUMBER DEFAULT 1800, p_interval_sec IN NUMBER DEFAULT 5 ) IS l_start TIMESTAMP := SYSTIMESTAMP; l_status t_project_status; BEGIN LOOP l_status := get_status(p_project_id); EXIT WHEN l_status.is_completed; IF EXTRACT(SECOND FROM (SYSTIMESTAMP - l_start)) + EXTRACT(MINUTE FROM (SYSTIMESTAMP - l_start)) * 60 + EXTRACT(HOUR FROM (SYSTIMESTAMP - l_start)) * 3600 > p_timeout_sec THEN RAISE_APPLICATION_ERROR(-20111, 'Timeout nach ' || p_timeout_sec || 's – Projekt ' || p_project_id || ' noch im Status ' || l_status.status || ' (' || l_status.processing_progress || '%).'); END IF; DBMS_SESSION.SLEEP(p_interval_sec); END LOOP; END wait_for_completion; -- ------------------------------------------------------------------------- FUNCTION convert_markdown( p_markdown IN CLOB, p_format IN VARCHAR2 DEFAULT 'PDF' ) RETURN BLOB IS l_url VARCHAR2(500); l_body CLOB; l_result BLOB; l_status NUMBER; BEGIN l_url := c_base_url || '/export/markdown'; -- JSON-Body mit CLOB-Inhalt: APEX_JSON unterstützt CLOBs beliebiger Größe APEX_JSON.INITIALIZE_CLOB_OUTPUT; APEX_JSON.OPEN_OBJECT; APEX_JSON.WRITE('content', p_markdown); APEX_JSON.WRITE('format', UPPER(p_format)); APEX_JSON.CLOSE_OBJECT; l_body := APEX_JSON.GET_CLOB_OUTPUT; APEX_JSON.FREE_OUTPUT; set_headers(); l_result := APEX_WEB_SERVICE.MAKE_REST_REQUEST_B( p_url => l_url, p_http_method => 'POST', p_body => l_body ); l_status := APEX_WEB_SERVICE.G_STATUS_CODE; IF l_status NOT IN (200, 201) THEN RAISE_APPLICATION_ERROR(-20200, 'Export-Service Fehler: HTTP ' || l_status || ' (format=' || p_format || ')'); END IF; RETURN l_result; EXCEPTION WHEN OTHERS THEN IF SQLCODE = -20200 THEN RAISE; END IF; RAISE_APPLICATION_ERROR(-20201, 'Verbindungsfehler beim Export: ' || SQLERRM); END convert_markdown; -- ------------------------------------------------------------------------- FUNCTION project_report( p_project_id IN NUMBER, p_format IN VARCHAR2 DEFAULT 'PDF' ) RETURN BLOB IS l_markdown CLOB; BEGIN SELECT report_markdown INTO l_markdown FROM dc_projects WHERE id = p_project_id; IF l_markdown IS NULL OR DBMS_LOB.GETLENGTH(l_markdown) = 0 THEN RAISE_APPLICATION_ERROR(-20202, 'Projekt ' || p_project_id || ' hat noch keinen Markdown-Bericht. ' || 'Bitte zuerst POST /api/dc/projects/' || p_project_id || '/report aufrufen.'); END IF; RETURN convert_markdown(l_markdown, p_format); EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20110, 'Projekt ' || p_project_id || ' nicht gefunden.'); END project_report; -- ------------------------------------------------------------------------- PROCEDURE regenerate_report( p_project_id IN NUMBER ) IS BEGIN DC_UTILS_PKG.generate_report(p_project_id => p_project_id); EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20110, 'Projekt ' || p_project_id || ' nicht gefunden.'); END regenerate_report; -- ------------------------------------------------------------------------- -- Interne Hilfsfunktion: schreibt einen VARCHAR2-Text als UTF8-Bytes in ein BLOB. -- ------------------------------------------------------------------------- PROCEDURE append_text_to_blob(p_dest IN OUT BLOB, p_text IN VARCHAR2) IS l_raw RAW(32767); BEGIN l_raw := UTL_RAW.CAST_TO_RAW(p_text); DBMS_LOB.WRITEAPPEND(p_dest, UTL_RAW.LENGTH(l_raw), l_raw); END append_text_to_blob; -- ------------------------------------------------------------------------- FUNCTION get_catalog_status( p_catalog_id IN NUMBER ) RETURN VARCHAR2 IS l_status VARCHAR2(20); BEGIN SELECT generation_status INTO l_status FROM dc_question_catalogs WHERE id = p_catalog_id; RETURN l_status; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20310, 'Katalog ' || p_catalog_id || ' nicht gefunden.'); END get_catalog_status; -- ------------------------------------------------------------------------- PROCEDURE wait_for_catalog( p_catalog_id IN NUMBER, p_timeout_sec IN NUMBER DEFAULT 1800, p_interval_sec IN NUMBER DEFAULT 10 ) IS l_start TIMESTAMP := SYSTIMESTAMP; l_status VARCHAR2(20); l_elapsed NUMBER; BEGIN LOOP l_status := get_catalog_status(p_catalog_id); IF l_status = 'READY' THEN RETURN; END IF; IF l_status = 'ERROR' THEN DECLARE l_error VARCHAR2(4000); BEGIN SELECT generation_error INTO l_error FROM dc_question_catalogs WHERE id = p_catalog_id; RAISE_APPLICATION_ERROR(-20311, 'Katalog ' || p_catalog_id || ' Generierung fehlgeschlagen: ' || NVL(l_error, 'Unbekannter Fehler')); END; END IF; -- Timeout prüfen l_elapsed := EXTRACT(SECOND FROM (SYSTIMESTAMP - l_start)) + EXTRACT(MINUTE FROM (SYSTIMESTAMP - l_start)) * 60 + EXTRACT(HOUR FROM (SYSTIMESTAMP - l_start)) * 3600; IF l_elapsed > p_timeout_sec THEN RAISE_APPLICATION_ERROR(-20312, 'Timeout nach ' || p_timeout_sec || 's – Katalog ' || p_catalog_id || ' noch im Status ' || l_status || '.'); END IF; DBMS_SESSION.SLEEP(p_interval_sec); END LOOP; END wait_for_catalog; -- ------------------------------------------------------------------------- FUNCTION generate_catalog( p_file IN BLOB, p_mime_type IN VARCHAR2 DEFAULT 'application/pdf', p_filename IN VARCHAR2 DEFAULT 'dokument.pdf', p_catalog_name IN VARCHAR2, p_document_type_id IN NUMBER, p_description IN VARCHAR2 DEFAULT NULL ) RETURN NUMBER IS c_crlf CONSTANT VARCHAR2(2) := CHR(13) || CHR(10); l_boundary VARCHAR2(50) := 'dc-boundary-' || TO_CHAR(SYSDATE, 'YYYYMMDDHHMI') || DBMS_RANDOM.STRING('X', 8); l_url VARCHAR2(500); l_body BLOB; l_response CLOB; l_status NUMBER; l_catalog_id NUMBER; -- Hilfsfunktion: multipart-Textfeld anfügen PROCEDURE add_field(p_name VARCHAR2, p_value VARCHAR2) IS BEGIN IF p_value IS NULL THEN RETURN; END IF; append_text_to_blob(l_body, '--' || l_boundary || c_crlf || 'Content-Disposition: form-data; name="' || p_name || '"' || c_crlf || c_crlf || p_value || c_crlf); END add_field; BEGIN l_url := c_base_url || '/catalog/generate'; -- ---- multipart-Body aufbauen ---- DBMS_LOB.CREATETEMPORARY(l_body, TRUE); -- Textfelder add_field('catalog_name', p_catalog_name); add_field('document_type_id', TO_CHAR(p_document_type_id)); add_field('description', p_description); -- Datei-Part: Header append_text_to_blob(l_body, '--' || l_boundary || c_crlf || 'Content-Disposition: form-data; name="file"; filename="' || p_filename || '"' || c_crlf || 'Content-Type: ' || p_mime_type || c_crlf || c_crlf); -- Datei-Part: BLOB-Inhalt anhängen DBMS_LOB.APPEND(l_body, p_file); -- Datei-Part: Abschluss-CRLF append_text_to_blob(l_body, c_crlf); -- Abschließende Boundary append_text_to_blob(l_body, '--' || l_boundary || '--' || c_crlf); -- ---- HTTP-Request senden ---- APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE; APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).NAME := 'Content-Type'; APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).VALUE := 'multipart/form-data; boundary=' || l_boundary; IF g_api_key IS NOT NULL AND LENGTH(TRIM(g_api_key)) > 0 THEN APEX_WEB_SERVICE.G_REQUEST_HEADERS(2).NAME := 'X-API-KEY'; APEX_WEB_SERVICE.G_REQUEST_HEADERS(2).VALUE := g_api_key; END IF; l_response := APEX_WEB_SERVICE.MAKE_REST_REQUEST( p_url => l_url, p_http_method => 'POST', p_body_blob => l_body ); DBMS_LOB.FREETEMPORARY(l_body); l_status := APEX_WEB_SERVICE.G_STATUS_CODE; -- 202 Accepted = asynchrone Verarbeitung gestartet IF l_status NOT IN (200, 201, 202) THEN RAISE_APPLICATION_ERROR(-20300, 'Katalog-Generierung fehlgeschlagen: HTTP ' || l_status || ' – ' || SUBSTR(l_response, 1, 500)); END IF; -- catalog_id aus JSON-Antwort lesen: -- {"catalog_id": 42, "catalog_name": "...", "generation_status": "GENERATING"} l_catalog_id := TO_NUMBER(JSON_VALUE(l_response, '$.catalog_id')); IF l_catalog_id IS NULL THEN RAISE_APPLICATION_ERROR(-20301, 'Antwort enthält keine catalog_id: ' || SUBSTR(l_response, 1, 500)); END IF; RETURN l_catalog_id; EXCEPTION WHEN OTHERS THEN IF DBMS_LOB.ISTEMPORARY(l_body) = 1 THEN DBMS_LOB.FREETEMPORARY(l_body); END IF; IF SQLCODE IN (-20300, -20301) THEN RAISE; END IF; RAISE_APPLICATION_ERROR(-20302, 'Verbindungsfehler bei Katalog-Generierung: ' || SQLERRM); END generate_catalog; END DC_BACKEND_PKG; / -- ============================================================================= -- Schnelltest (optional, auskommentiert) -- ============================================================================= /* -- Health-Check BEGIN IF DC_BACKEND_PKG.health_check() THEN DBMS_OUTPUT.PUT_LINE('dc-backend: UP'); ELSE DBMS_OUTPUT.PUT_LINE('dc-backend: NICHT ERREICHBAR'); END IF; END; / -- Prüfung starten (Projekt-ID anpassen) DECLARE l_status NUMBER; l_response VARCHAR2(4000); BEGIN DC_BACKEND_PKG.start_check( p_project_id => 1, p_status_code => l_status, p_response => l_response ); DBMS_OUTPUT.PUT_LINE('Status: ' || l_status); DBMS_OUTPUT.PUT_LINE('Response: ' || l_response); END; / -- Bericht als PDF exportieren DECLARE l_blob BLOB; BEGIN DC_BACKEND_PKG.g_api_key := 'mein-api-key'; l_blob := DC_BACKEND_PKG.project_report(p_project_id => 1, p_format => 'PDF'); DBMS_OUTPUT.PUT_LINE('PDF-Größe: ' || DBMS_LOB.GETLENGTH(l_blob) || ' Bytes'); END; / -- Bericht als DOCX exportieren DECLARE l_blob BLOB; BEGIN DC_BACKEND_PKG.g_api_key := 'mein-api-key'; l_blob := DC_BACKEND_PKG.project_report(p_project_id => 1, p_format => 'DOCX'); DBMS_OUTPUT.PUT_LINE('DOCX-Größe: ' || DBMS_LOB.GETLENGTH(l_blob) || ' Bytes'); END; / */