feat: Add catalog generation functionality and related models

- Implemented CatalogResource for handling catalog generation requests and status checks.
- Created CatalogGenerationService to manage the asynchronous generation process.
- Added models for catalog generation responses, requests, and structured catalog data.
- Introduced utility scripts for database interaction and Kubernetes pod retrieval.
- Enhanced error handling and logging throughout the catalog generation workflow.
This commit is contained in:
Wolf G. Beckmann
2026-05-04 19:03:54 +02:00
parent 522bc21439
commit ac9ab7ab15
21 changed files with 1853 additions and 202 deletions

View File

@@ -1,193 +1,8 @@
-- =============================================================================
-- dc_generate_report Erstellt einen Markdown-Prüfbericht für ein Projekt
-- Migration: dc_generate_report → DC_UTILS_PKG.generate_report
--
-- Aufruf:
-- BEGIN dc_generate_report(p_project_id => 1); COMMIT; END;
-- /
--
-- Ergebnis: dc_projects.report_markdown wird mit dem Markdown-Text befüllt.
-- Wird automatisch beim POST /api/dc/projects/:id/complete aufgerufen.
-- Kann manuell via POST /api/dc/projects/:id/report neu generiert werden.
--
-- Aufbau des Berichts:
-- 1. Kopf (Projektname, Abschlussdatum)
-- 2. Zusammenfassung (OK / Hinweise / Abweichungen)
-- 3. Ergebnisse: je Dokument → Kategorie (order_nr) → Frage (order_nr)
-- Icons: ✅ OK | ⚠️ Hinweis | ❌ Abweichung | ❓ Unklar
-- Die Standalone-Prozedur wurde in DC_UTILS_PKG.generate_report überführt.
-- Dieses Skript entfernt die veraltete Prozedur aus der Datenbank.
-- =============================================================================
CREATE OR REPLACE PROCEDURE dc_generate_report(p_project_id IN NUMBER) IS
v_md CLOB;
v_name dc_projects.name%TYPE;
v_status dc_projects.status%TYPE;
v_done dc_projects.completed_at%TYPE;
v_ok_cnt NUMBER := 0;
v_warn_cnt NUMBER := 0;
v_dev_cnt NUMBER := 0;
v_unkl_cnt NUMBER := 0;
v_total NUMBER := 0;
-- Emoji-Konstanten via UNISTR (vermeidet Quellcode-Encoding-Probleme)
C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2705'); -- ✅
C_NOK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\274C'); -- ❌
C_WARN CONSTANT VARCHAR2(10 CHAR) := UNISTR('\26A0\FE0F'); -- ⚠️
C_UNKL CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2753'); -- ❓
C_DOC CONSTANT VARCHAR2(10 CHAR) := UNISTR('\D83D\DCC4'); -- 📄
CURSOR c_docs IS
SELECT d.id, d.filename
FROM dc_project_documents d
WHERE d.project_id = p_project_id
ORDER BY d.uploaded_at, d.id;
CURSOR c_cats(p_doc_id NUMBER) IS
SELECT DISTINCT qc.id, qc.name, qc.order_nr
FROM dc_question_categories qc
JOIN dc_questions q ON q.category_id = qc.id
JOIN dc_results r ON r.question_id = q.id
WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_id
ORDER BY qc.order_nr, qc.name;
CURSOR c_qs(p_doc_id NUMBER, p_cat_id NUMBER) IS
SELECT q.question_text,
r.result_type,
r.score,
r.deviation,
r.warning,
r.answer,
r.the_comment
FROM dc_questions q
JOIN dc_results r ON r.question_id = q.id
WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_id
AND q.category_id = p_cat_id
ORDER BY q.order_nr, q.id;
v_icon VARCHAR2(20 CHAR);
-- Fügt eine Zeile + Newline an den CLOB an. Akzeptiert CLOB damit kein
-- implizites CLOB→VARCHAR2-Cast nötig ist (question_text etc. sind CLOBs).
PROCEDURE ln(p_text IN CLOB DEFAULT NULL) IS
BEGIN
IF p_text IS NOT NULL AND DBMS_LOB.GETLENGTH(p_text) > 0 THEN
DBMS_LOB.APPEND(v_md, p_text);
END IF;
DBMS_LOB.APPEND(v_md, TO_CLOB(CHR(10)));
END ln;
BEGIN
DBMS_LOB.CREATETEMPORARY(v_md, TRUE);
SELECT name, status, completed_at
INTO v_name, v_status, v_done
FROM dc_projects
WHERE id = p_project_id;
SELECT SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN warning = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN result_type = 'UNKLAR'
AND deviation = 0
AND warning = 0 THEN 1 ELSE 0 END),
COUNT(*)
INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total
FROM dc_results
WHERE project_id = p_project_id;
v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt - v_unkl_cnt;
-- =========================================================================
-- Kopf
-- =========================================================================
ln('# ' || C_OK || ' Prüfbericht: ' || v_name);
ln();
IF v_done IS NOT NULL THEN
ln('**Abgeschlossen:** ' || TO_CHAR(v_done, 'DD.MM.YYYY HH24:MI'));
ELSE
ln('**Erstellt:** ' || TO_CHAR(SYSDATE, 'DD.MM.YYYY HH24:MI'));
END IF;
ln();
ln('---');
ln();
-- =========================================================================
-- Zusammenfassung (keine Tabelle Plaintext für maximale Kompatibilität)
-- =========================================================================
ln('## Zusammenfassung');
ln();
ln(C_OK || ' OK: ' || v_ok_cnt);
ln(C_WARN || ' Hinweise: ' || v_warn_cnt);
ln(C_NOK || ' Abweichungen: ' || v_dev_cnt);
IF v_unkl_cnt > 0 THEN
ln(C_UNKL || ' Unklar: ' || v_unkl_cnt);
END IF;
ln('**Gesamt: ' || v_total || '**');
ln();
ln('---');
ln();
-- =========================================================================
-- Ergebnisse je Dokument → Kategorie (order_nr) → Frage (order_nr)
-- =========================================================================
ln('## Ergebnisse');
ln();
FOR doc IN c_docs LOOP
ln('## ' || C_DOC || ' ' || doc.filename);
ln();
FOR cat IN c_cats(doc.id) LOOP
ln();
ln('### ' || cat.name);
ln();
FOR q IN c_qs(doc.id, cat.id) LOOP
IF q.deviation = 1 THEN v_icon := C_NOK;
ELSIF q.warning = 1 THEN v_icon := C_WARN;
ELSIF q.result_type = 'UNKLAR' THEN v_icon := C_UNKL;
ELSE v_icon := C_OK;
END IF;
ln();
ln(v_icon || ' **' || q.question_text || '**');
ln();
IF q.answer IS NOT NULL AND DBMS_LOB.GETLENGTH(q.answer) > 0 THEN
ln(q.answer);
ln();
END IF;
IF q.score IS NOT NULL THEN
ln('*Score: ' || q.score || '%*');
END IF;
IF q.the_comment IS NOT NULL
AND DBMS_LOB.GETLENGTH(q.the_comment) > 0 THEN
ln();
ln('> ' || q.the_comment);
END IF;
ln();
END LOOP;
END LOOP;
ln('---');
ln();
END LOOP;
UPDATE dc_projects
SET report_markdown = v_md
WHERE id = p_project_id;
DBMS_LOB.FREETEMPORARY(v_md);
EXCEPTION
WHEN OTHERS THEN
DBMS_LOB.FREETEMPORARY(v_md);
RAISE;
END dc_generate_report;
/
DROP PROCEDURE dc_generate_report;

View File

@@ -122,7 +122,7 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS
-- -------------------------------------------------------------------------
-- Generiert den Markdown-Prüfbericht für ein Projekt neu und speichert ihn
-- in dc_projects.report_markdown. Ruft intern dc_generate_report auf.
-- in dc_projects.report_markdown. Ruft intern DC_UTILS_PKG.generate_report auf.
--
-- Beispiel:
-- DC_BACKEND_PKG.regenerate_report(1);
@@ -131,6 +131,66 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS
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;
/
@@ -376,7 +436,7 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
p_project_id IN NUMBER
) IS
BEGIN
dc_generate_report(p_project_id => p_project_id);
DC_UTILS_PKG.generate_report(p_project_id => p_project_id);
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20110,
@@ -384,6 +444,190 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
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;
/

View File

@@ -0,0 +1,47 @@
-- =============================================================================
-- DC_CATALOG_GEN_ALTER.sql
-- Ergänzt dc_question_catalogs um Spalten für asynchrone Katalog-Generierung.
-- Idempotent: Fehler "Spalte existiert bereits" wird ignoriert.
-- =============================================================================
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_question_catalogs ADD (
generation_status VARCHAR2(20) DEFAULT ''READY'',
generation_error VARCHAR2(4000)
)
';
DBMS_OUTPUT.PUT_LINE('Spalten generation_status/generation_error angelegt.');
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE = -1430 THEN
DBMS_OUTPUT.PUT_LINE('Spalten existieren bereits kein Änderungsbedarf.');
ELSE
RAISE;
END IF;
END;
/
-- Bestehende Zeilen bekommen Status READY (Kataloge wurden manuell erstellt)
UPDATE dc_question_catalogs
SET generation_status = 'READY'
WHERE generation_status IS NULL;
COMMIT;
-- Constraint nachträglich hinzufügen (idempotent)
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_question_catalogs
ADD CONSTRAINT chk_cat_gen_status
CHECK (generation_status IN (''GENERATING'', ''READY'', ''ERROR''))
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261) THEN NULL; -- Constraint existiert bereits
ELSE RAISE;
END IF;
END;
/
COMMIT;

231
Scripts/DC_UTILS_PKG.sql Normal file
View File

@@ -0,0 +1,231 @@
-- =============================================================================
-- DC_UTILS_PKG
-- Hilfsprozeduren und -funktionen für den Dokumenten-Check
--
-- Deployment:
-- sqlplus user/pass@db @"Scripts/DC_UTILS_PKG.sql"
-- =============================================================================
-- =============================================================================
-- Package Spec
-- =============================================================================
CREATE OR REPLACE PACKAGE DC_UTILS_PKG AS
-- -------------------------------------------------------------------------
-- Erstellt einen Markdown-Prüfbericht für ein Projekt und speichert ihn
-- in dc_projects.report_markdown.
--
-- Aufbau des Berichts:
-- 1. Kopf (Projektname, Abschlussdatum)
-- 2. Zusammenfassung (OK / Hinweise / Abweichungen)
-- 3. Ergebnisse: je Dokument → Kategorie (order_nr) → Frage (order_nr)
-- Icons: ✅ OK | ⚠️ Hinweis | ❌ Abweichung | ❓ Unklar
--
-- Beispiel:
-- BEGIN DC_UTILS_PKG.generate_report(p_project_id => 1); COMMIT; END;
-- -------------------------------------------------------------------------
PROCEDURE generate_report(p_project_id IN NUMBER);
END DC_UTILS_PKG;
/
-- =============================================================================
-- Package Body
-- =============================================================================
CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
-- -------------------------------------------------------------------------
PROCEDURE generate_report(p_project_id IN NUMBER) IS
v_md CLOB;
v_name dc_projects.name%TYPE;
v_status dc_projects.status%TYPE;
v_done dc_projects.completed_at%TYPE;
v_ok_cnt NUMBER := 0;
v_warn_cnt NUMBER := 0;
v_dev_cnt NUMBER := 0;
v_unkl_cnt NUMBER := 0;
v_total NUMBER := 0;
-- Emoji-Konstanten via UNISTR (vermeidet Quellcode-Encoding-Probleme)
C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2705'); -- ✅
C_NOK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\274C'); -- ❌
C_WARN CONSTANT VARCHAR2(10 CHAR) := UNISTR('\26A0\FE0F'); -- ⚠️
C_UNKL CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2753'); -- ❓
C_DOC CONSTANT VARCHAR2(10 CHAR) := UNISTR('\D83D\DCC4'); -- 📄
CURSOR c_docs IS
SELECT d.id, d.filename
FROM dc_project_documents d
WHERE d.project_id = p_project_id
ORDER BY d.uploaded_at, d.id;
CURSOR c_cats(p_doc_id NUMBER) IS
SELECT DISTINCT qc.id, qc.name, qc.order_nr
FROM dc_question_categories qc
JOIN dc_questions q ON q.category_id = qc.id
JOIN dc_results r ON r.question_id = q.id
WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_id
ORDER BY qc.order_nr, qc.name;
CURSOR c_qs(p_doc_id NUMBER, p_cat_id NUMBER) IS
SELECT q.question_text,
r.result_type,
r.score,
r.deviation,
r.warning,
r.answer,
r.the_comment
FROM dc_questions q
JOIN dc_results r ON r.question_id = q.id
WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_id
AND q.category_id = p_cat_id
ORDER BY q.order_nr, q.id;
v_icon VARCHAR2(20 CHAR);
-- Fügt eine Zeile + Newline an den CLOB an. Akzeptiert CLOB damit kein
-- implizites CLOB→VARCHAR2-Cast nötig ist (question_text etc. sind CLOBs).
PROCEDURE ln(p_text IN CLOB DEFAULT NULL) IS
BEGIN
IF p_text IS NOT NULL AND DBMS_LOB.GETLENGTH(p_text) > 0 THEN
DBMS_LOB.APPEND(v_md, p_text);
END IF;
DBMS_LOB.APPEND(v_md, TO_CLOB(CHR(13)||CHR(10)));
END ln;
BEGIN
DBMS_LOB.CREATETEMPORARY(v_md, TRUE);
SELECT name, status, completed_at
INTO v_name, v_status, v_done
FROM dc_projects
WHERE id = p_project_id;
SELECT SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN warning = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN result_type = 'UNKLAR'
AND deviation = 0
AND warning = 0 THEN 1 ELSE 0 END),
COUNT(*)
INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total
FROM dc_results
WHERE project_id = p_project_id;
v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt - v_unkl_cnt;
-- =====================================================================
-- Kopf
-- =====================================================================
ln('# Prüfbericht: ' || v_name);
ln();
IF v_done IS NOT NULL THEN
ln('**Abgeschlossen:** ' || TO_CHAR(v_done, 'DD.MM.YYYY HH24:MI'));
ELSE
ln('**Erstellt:** ' || TO_CHAR(SYSDATE, 'DD.MM.YYYY HH24:MI'));
END IF;
ln();
ln('---');
ln();
-- =====================================================================
-- Zusammenfassung (keine Tabelle Plaintext für maximale Kompatibilität)
-- =====================================================================
ln('## Zusammenfassung');
ln();
ln(C_OK || ' OK: ' || v_ok_cnt);
ln(C_WARN || ' Hinweise: ' || v_warn_cnt);
ln(C_NOK || ' Abweichungen: ' || v_dev_cnt);
IF v_unkl_cnt > 0 THEN
ln(C_UNKL || ' Unklar: ' || v_unkl_cnt);
END IF;
ln('**Gesamt: ' || v_total || '**');
ln();
ln('---');
ln();
-- =====================================================================
-- Ergebnisse je Dokument → Kategorie (order_nr) → Frage (order_nr)
-- =====================================================================
ln('## Ergebnisse');
ln();
FOR doc IN c_docs LOOP
ln('## ' || C_DOC || ' ' || doc.filename);
ln();
FOR cat IN c_cats(doc.id) LOOP
ln();
ln('### ' || cat.name);
ln();
FOR q IN c_qs(doc.id, cat.id) LOOP
IF q.deviation = 1 THEN v_icon := C_NOK;
ELSIF q.warning = 1 THEN v_icon := C_WARN;
ELSIF q.result_type = 'UNKLAR' THEN v_icon := C_UNKL;
ELSE v_icon := C_OK;
END IF;
ln();
ln();
ln(v_icon || ' **' || q.question_text || '**');
ln();
IF q.answer IS NOT NULL AND DBMS_LOB.GETLENGTH(q.answer) > 0 THEN
ln(q.answer);
ln();
END IF;
IF q.score IS NOT NULL THEN
ln('*Score: ' || q.score || '%*');
END IF;
IF q.the_comment IS NOT NULL
AND DBMS_LOB.GETLENGTH(q.the_comment) > 0 THEN
ln();
ln('> ' || q.the_comment);
END IF;
ln();
END LOOP;
END LOOP;
ln('---');
ln();
END LOOP;
UPDATE dc_projects
SET report_markdown = v_md
WHERE id = p_project_id;
DBMS_LOB.FREETEMPORARY(v_md);
EXCEPTION
WHEN OTHERS THEN
DBMS_LOB.FREETEMPORARY(v_md);
RAISE;
END generate_report;
END DC_UTILS_PKG;
/
-- =============================================================================
-- Schnelltest (optional, auskommentiert)
-- =============================================================================
/*
BEGIN
DC_UTILS_PKG.generate_report(p_project_id => 1);
COMMIT;
DBMS_OUTPUT.PUT_LINE('Bericht generiert.');
END;
/
*/

View File

@@ -365,7 +365,7 @@ BEGIN
-- Markdown-Bericht generieren (Fehler ignoriert, Projekt bleibt COMPLETED)
BEGIN
dc_generate_report(:project_id);
DC_UTILS_PKG.generate_report(:project_id);
EXCEPTION WHEN OTHERS THEN NULL;
END;
@@ -406,7 +406,7 @@ BEGIN
p_comments => 'Generiert report_markdown neu (z.B. nach Konfigurationsänderung)',
p_source => q'[
BEGIN
dc_generate_report(:project_id);
DC_UTILS_PKG.generate_report(:project_id);
:status_code := 200;
HTP.P('{"project_id":' || :project_id || ',"report_generated":true}');
EXCEPTION
@@ -731,6 +731,301 @@ BEGIN
]'
);
-- ===========================================================================
-- TEMPLATE 13: catalogs/ POST (Katalog anlegen)
-- Ergaenzt das bestehende catalogs/-Template um eine Schreib-Operation.
-- ===========================================================================
-- 13. POST /api/dc/catalogs/
-- Body: {"name":"ADSp 2017","description":"...","document_type_id":1}
-- Legt den Katalog mit generation_status=GENERATING an (async Flow).
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => '201 Created mit neuer catalog_id; 400 bei fehlendem name/document_type_id',
p_source => q'[
DECLARE
v_body CLOB;
v_name VARCHAR2(100);
v_desc CLOB;
v_dt_id NUMBER;
v_new_id NUMBER;
BEGIN
v_body := :body_text;
v_name := JSON_VALUE(v_body, '$.name');
v_desc := JSON_VALUE(v_body, '$.description' RETURNING CLOB);
v_dt_id := TO_NUMBER(JSON_VALUE(v_body, '$.document_type_id'));
IF v_name IS NULL OR v_dt_id IS NULL THEN
:status_code := 400;
HTP.P('{"error":"name und document_type_id sind Pflichtfelder"}');
RETURN;
END IF;
INSERT INTO dc_question_catalogs (name, description, document_type_id, generation_status)
VALUES (v_name, v_desc, v_dt_id, 'GENERATING')
RETURNING id INTO v_new_id;
:status_code := 201;
HTP.P('{"id":' || v_new_id
|| ',"name":"' || REPLACE(v_name, '"', '''') || '"'
|| ',"document_type_id":' || v_dt_id
|| ',"generation_status":"GENERATING"}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 16: catalogs/:catalog_id/generation-status (GET + PUT)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/generation-status',
p_priority => 0,
p_comments => 'Status der asynchronen Katalog-Generierung lesen/schreiben'
);
-- 16a. GET /api/dc/catalogs/:catalog_id/generation-status
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/generation-status',
p_method => 'GET',
p_source_type => ORDS.SOURCE_TYPE_COLLECTION_ITEM,
p_items_per_page => 1,
p_comments => 'Gibt generation_status und generation_error zurueck',
p_source => q'[
SELECT
c.id AS catalog_id,
c.name,
c.description,
c.generation_status,
c.generation_error
FROM dc_question_catalogs c
WHERE c.id = :catalog_id
]'
);
-- 16b. PUT /api/dc/catalogs/:catalog_id/generation-status
-- Body: {"generation_status":"READY"} oder {"generation_status":"ERROR","generation_error":"..."}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/generation-status',
p_method => 'PUT',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt generation_status (GENERATING|READY|ERROR) und optionalen Fehlertext',
p_source => q'[
DECLARE
v_body CLOB;
v_status VARCHAR2(20);
v_error VARCHAR2(4000);
v_rows INTEGER;
BEGIN
v_body := :body_text;
v_status := JSON_VALUE(v_body, '$.generation_status');
v_error := JSON_VALUE(v_body, '$.generation_error');
IF v_status NOT IN ('GENERATING', 'READY', 'ERROR') THEN
:status_code := 400;
HTP.P('{"error":"generation_status muss GENERATING, READY oder ERROR sein"}');
RETURN;
END IF;
UPDATE dc_question_catalogs
SET generation_status = v_status,
generation_error = v_error
WHERE id = :catalog_id;
v_rows := SQL%ROWCOUNT;
IF v_rows = 0 THEN
:status_code := 404;
HTP.P('{"error":"Katalog nicht gefunden","catalog_id":' || :catalog_id || '}');
RETURN;
END IF;
:status_code := 200;
HTP.P('{"catalog_id":' || :catalog_id
|| ',"generation_status":"' || v_status || '"'
|| ',"updated":true}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 14: catalogs/:catalog_id/categories/ (POST)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/categories/',
p_priority => 0,
p_comments => 'Fragenkategorien eines Katalogs anlegen'
);
-- 14. POST /api/dc/catalogs/:catalog_id/categories/
-- Body: {"name":"Haftung","description":"...","order_nr":1}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'catalogs/:catalog_id/categories/',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => '201 Created mit neuer category_id; 400 bei fehlendem name',
p_source => q'[
DECLARE
v_body CLOB;
v_name VARCHAR2(100);
v_desc CLOB;
v_order NUMBER;
v_new_id NUMBER;
BEGIN
v_body := :body_text;
v_name := JSON_VALUE(v_body, '$.name');
v_desc := JSON_VALUE(v_body, '$.description' RETURNING CLOB);
v_order := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.order_nr')), 0);
IF v_name IS NULL THEN
:status_code := 400;
HTP.P('{"error":"name ist ein Pflichtfeld"}');
RETURN;
END IF;
INSERT INTO dc_question_categories (catalog_id, name, description, order_nr)
VALUES (:catalog_id, v_name, v_desc, v_order)
RETURNING id INTO v_new_id;
:status_code := 201;
HTP.P('{"id":' || v_new_id
|| ',"catalog_id":' || :catalog_id
|| ',"name":"' || REPLACE(v_name, '"', '''') || '"'
|| ',"order_nr":' || v_order || '}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 15: categories/:category_id/questions/ (POST)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'categories/:category_id/questions/',
p_priority => 0,
p_comments => 'Fragen einer Kategorie anlegen'
);
-- 15. POST /api/dc/categories/:category_id/questions/
-- Body: {
-- "question_text": "...",
-- "evaluation_type": "SCORE",
-- "threshold": 70,
-- "result_handling": "ABWEICHUNG",
-- "example_0_percent": "...",
-- "example_100_percent": "...",
-- "order_nr": 1
-- }
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'categories/:category_id/questions/',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => '201 Created mit neuer question_id; 400 bei fehlendem question_text',
p_source => q'[
DECLARE
v_body CLOB;
v_text CLOB;
v_eval_type VARCHAR2(20);
v_threshold NUMBER;
v_handling VARCHAR2(20);
v_ex0 CLOB;
v_ex100 CLOB;
v_order NUMBER;
v_new_id NUMBER;
BEGIN
v_body := :body_text;
v_text := JSON_VALUE(v_body, '$.question_text' RETURNING CLOB);
v_eval_type := NVL(JSON_VALUE(v_body, '$.evaluation_type'), 'SCORE');
v_threshold := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.threshold')), 70);
v_handling := NVL(JSON_VALUE(v_body, '$.result_handling'), 'ABWEICHUNG');
v_ex0 := JSON_VALUE(v_body, '$.example_0_percent' RETURNING CLOB);
v_ex100 := JSON_VALUE(v_body, '$.example_100_percent' RETURNING CLOB);
v_order := NVL(TO_NUMBER(JSON_VALUE(v_body, '$.order_nr')), 0);
IF v_text IS NULL THEN
:status_code := 400;
HTP.P('{"error":"question_text ist ein Pflichtfeld"}');
RETURN;
END IF;
IF v_eval_type NOT IN ('SCORE', 'BINARY') THEN
:status_code := 400;
HTP.P('{"error":"evaluation_type muss SCORE oder BINARY sein"}');
RETURN;
END IF;
IF v_handling NOT IN ('ABWEICHUNG', 'HINWEIS') THEN
:status_code := 400;
HTP.P('{"error":"result_handling muss ABWEICHUNG oder HINWEIS sein"}');
RETURN;
END IF;
INSERT INTO dc_questions (
category_id,
question_text,
evaluation_type,
threshold,
result_handling,
example_0_percent,
example_100_percent,
order_nr
) VALUES (
:category_id,
v_text,
v_eval_type,
v_threshold,
v_handling,
v_ex0,
v_ex100,
v_order
)
RETURNING id INTO v_new_id;
:status_code := 201;
HTP.P('{"id":' || v_new_id
|| ',"category_id":' || :category_id
|| ',"evaluation_type":"' || v_eval_type || '"'
|| ',"threshold":' || v_threshold
|| ',"order_nr":' || v_order || '}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
COMMIT;
END;
/