feat: Externe Dokument-Referenzen erkennen, herunterladen & mitbewerten

Nach der Transkription eines Dokuments werden referenzierte externe
Dokumente (AGB, Verhaltenskodex) erkannt, sicher heruntergeladen und ihr
Text dem Hauptdokument fuer die "Summen"-Bewertung angehaengt (Option D).
Die Referenzen werden zusaetzlich als eigene, herunterladbare Zeilen
gespeichert.

- DB: dc_project_documents um parent_document_id, is_reference, source_url
  erweitert (DC_REFERENCES_ALTER.sql)
- ORDS: Dokumentenliste um die Felder erweitert; neue Endpunkte
  POST /documents/:id/references und DELETE /projects/:id/references;
  q-Quote-Parserfehler im ai-costs-Handler ('[]') behoben
- Backend: ReferenceDownloadService (SSRF-sicher via DNS-/Public-IP-
  Validierung, Redirect-Pruefung, Groessen-/Timeout-Limits, jsoup fuer HTML),
  EvaluationService.extractReferenceUrls (LLM-basierte URL-Erkennung),
  Orchestrierung (Discovery in Phase A, Referenz-Skip + combinedText in Phase B)
- Config: dc.references.* in application.properties

Verifiziert an Projekt 144: AGB-PDF/-HTML heruntergeladen, verknuepft,
"Summen"-Bewertung zitiert AGB-Klauseln aus dem Referenzdokument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:45:55 +02:00
parent d00525c15f
commit c75f460ec5
91 changed files with 3949 additions and 255 deletions

View File

@@ -52,12 +52,28 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS
-- -------------------------------------------------------------------------
FUNCTION health_check RETURN BOOLEAN;
-- -------------------------------------------------------------------------
-- Setzt Projekt auf PENDING zurück eigene Transaktion (AUTONOMOUS COMMIT).
-- dc_projects.status → PENDING, report_markdown → NULL
-- dc_project_documents.original_text/translated_text → NULL, progress → 0
-- dc_results löschen
-- -------------------------------------------------------------------------
PROCEDURE reset_project(
p_project_id IN NUMBER
);
-- -------------------------------------------------------------------------
-- Startet die Prüfung und wirft eine Exception wenn nicht 202 zurückkommt.
-- Convenience-Wrapper für direkte APEX-Button-Aufrufe.
--
-- p_reset TRUE = Projekt vor dem Start zurücksetzen (eigener COMMIT):
-- dc_projects.status → PENDING, report_markdown → NULL
-- dc_project_documents.original_text/translated_text → NULL, progress → 0
-- dc_results löschen
-- -------------------------------------------------------------------------
PROCEDURE start_check_or_raise(
p_project_id IN NUMBER
p_project_id IN NUMBER,
p_reset IN BOOLEAN DEFAULT FALSE
);
-- -------------------------------------------------------------------------
@@ -200,6 +216,31 @@ END DC_BACKEND_PKG;
-- =============================================================================
CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
-- -------------------------------------------------------------------------
-- Setzt Projekt auf PENDING zurück eigene Transaktion (AUTONOMOUS).
-- -------------------------------------------------------------------------
PROCEDURE reset_project(p_project_id IN NUMBER) IS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
UPDATE dc_projects
SET status = 'PENDING',
report_markdown = NULL
WHERE id = p_project_id;
UPDATE dc_project_documents
SET original_text = NULL,
translated_text = NULL,
progress = 0,
status = 'PENDING'
WHERE project_id = p_project_id;
DELETE FROM dc_results
WHERE project_id = p_project_id;
COMMIT;
END reset_project;
-- -------------------------------------------------------------------------
-- Setzt gemeinsame Request-Header (Content-Type + optional API-Key)
-- -------------------------------------------------------------------------
@@ -274,11 +315,16 @@ CREATE OR REPLACE PACKAGE BODY DC_BACKEND_PKG AS
-- -------------------------------------------------------------------------
PROCEDURE start_check_or_raise(
p_project_id IN NUMBER
p_project_id IN NUMBER,
p_reset IN BOOLEAN DEFAULT FALSE
) IS
l_status NUMBER;
l_response VARCHAR2(4000);
BEGIN
IF p_reset THEN
reset_project(p_project_id);
END IF;
start_check(
p_project_id => p_project_id,
p_status_code => l_status,

View File

@@ -0,0 +1,50 @@
-- =============================================================================
-- DC_DOCUMENT_PROGRESS_ALTER.sql
-- Ergänzt dc_project_documents um status und progress für Dokument-Fortschritt.
-- Idempotent: Fehler "Spalte existiert bereits" wird ignoriert.
-- =============================================================================
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_project_documents ADD (
status VARCHAR2(20) DEFAULT ''PENDING'',
progress NUMBER DEFAULT 0
)
';
DBMS_OUTPUT.PUT_LINE('Spalten status/progress 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 initialisieren
UPDATE dc_project_documents
SET status = 'PENDING',
progress = 0
WHERE status IS NULL;
COMMIT;
-- Check-Constraint (idempotent)
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_project_documents
ADD CONSTRAINT dc_project_documents_status_ck
CHECK (status IN (''PENDING'', ''IN_PROGRESS'', ''COMPLETED'', ''ERROR''))
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261) THEN NULL; -- Constraint existiert bereits
ELSE RAISE;
END IF;
END;
/
COMMIT;
EXIT;

View File

@@ -0,0 +1,68 @@
-- =============================================================================
-- DC_DOCUMENT_PROGRESS_ORDS.sql
-- Registriert den ORDS-Endpunkt PUT /api/dc/documents/:doc_id/progress.
-- Idempotent: bereits vorhandene Templates/Handler werden überschrieben.
-- =============================================================================
BEGIN
-- Template (idempotent)
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/progress',
p_priority => 0,
p_comments => 'Dokument-Fortschritt und -Status schreiben'
);
-- PUT /api/dc/documents/:doc_id/progress
-- Body: {"progress":45,"status":"IN_PROGRESS"}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/progress',
p_method => 'PUT',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt progress (0-100) und/oder status des Dokuments',
p_source =>
'DECLARE
v_body CLOB;
v_progress NUMBER;
v_status VARCHAR2(20);
v_rows INTEGER;
BEGIN
v_body := :body_text;
v_progress := TO_NUMBER(JSON_VALUE(v_body, ''$.progress''));
v_status := JSON_VALUE(v_body, ''$.status'');
IF v_progress IS NOT NULL AND (v_progress < 0 OR v_progress > 100) THEN
:status_code := 400;
HTP.P(''{"error":"progress muss zwischen 0 und 100 liegen"}'');
RETURN;
END IF;
UPDATE dc_project_documents
SET progress = CASE WHEN JSON_EXISTS(v_body, ''$.progress'') THEN v_progress ELSE progress END,
status = CASE WHEN JSON_EXISTS(v_body, ''$.status'') THEN v_status ELSE status END
WHERE id = :doc_id;
v_rows := SQL%ROWCOUNT;
IF v_rows = 0 THEN
:status_code := 404;
HTP.P(''{"error":"Dokument nicht gefunden","doc_id":'' || :doc_id || ''}'');
RETURN;
END IF;
:status_code := 200;
HTP.P(''{"doc_id":'' || :doc_id || '',"updated":true}'');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P(''{"error":"'' || REPLACE(SQLERRM, ''"'', '''''''') || ''"}'');
END;'
);
COMMIT;
END;
/
EXIT;

View File

@@ -0,0 +1,71 @@
-- =============================================================================
-- DC_PROJECTS_NOTIFY_ALTER.sql
-- Ergänzt dc_projects um drei Berichtstyp-Flags für die E-Mail-Benachrichtigung.
-- Idempotent: bereits vorhandene Spalten werden ignoriert.
-- =============================================================================
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_projects ADD (
notify_ok CHAR(1) DEFAULT ''Y'' NOT NULL,
notify_hinweis CHAR(1) DEFAULT ''Y'' NOT NULL,
notify_abweichend CHAR(1) DEFAULT ''Y'' NOT NULL
)
';
DBMS_OUTPUT.PUT_LINE('Spalten notify_ok/notify_hinweis/notify_abweichend angelegt.');
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE = -1430 THEN
DBMS_OUTPUT.PUT_LINE('Spalten existieren bereits kein Änderungsbedarf.');
ELSE
RAISE;
END IF;
END;
/
-- Check-Constraints (idempotent)
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_projects
ADD CONSTRAINT dc_projects_notify_ok_ck
CHECK (notify_ok IN (''Y'',''N''))
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261) THEN NULL;
ELSE RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_projects
ADD CONSTRAINT dc_projects_notify_hinweis_ck
CHECK (notify_hinweis IN (''Y'',''N''))
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261) THEN NULL;
ELSE RAISE;
END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_projects
ADD CONSTRAINT dc_projects_notify_abweichend_ck
CHECK (notify_abweichend IN (''Y'',''N''))
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261) THEN NULL;
ELSE RAISE;
END IF;
END;
/
COMMIT;
EXIT;

View File

@@ -0,0 +1,81 @@
-- =============================================================================
-- DC_REFERENCES_ALTER.sql
-- Ergänzt dc_project_documents um Felder für heruntergeladene externe Referenzen.
-- parent_document_id → Self-FK auf das Originaldokument (NULL bei Originalen)
-- is_reference → 0 = Originaldokument, 1 = heruntergeladene Referenz
-- source_url → Ursprungs-URL der heruntergeladenen Referenz
-- Idempotent: Fehler "Spalte/Constraint existiert bereits" wird ignoriert.
-- =============================================================================
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_project_documents ADD (
parent_document_id NUMBER,
is_reference NUMBER DEFAULT 0,
source_url VARCHAR2(2000 CHAR)
)
';
DBMS_OUTPUT.PUT_LINE('Spalten parent_document_id/is_reference/source_url 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 initialisieren
UPDATE dc_project_documents
SET is_reference = 0
WHERE is_reference IS NULL;
COMMIT;
-- Self-FK (idempotent)
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_project_documents
ADD CONSTRAINT dc_project_documents_parent_fk
FOREIGN KEY (parent_document_id) REFERENCES dc_project_documents (id)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261, -2275) THEN NULL; -- Constraint existiert bereits
ELSE RAISE;
END IF;
END;
/
-- Check-Constraint is_reference (idempotent)
BEGIN
EXECUTE IMMEDIATE '
ALTER TABLE dc_project_documents
ADD CONSTRAINT dc_project_documents_is_ref_ck
CHECK (is_reference IN (0, 1))
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE IN (-2264, -2261) THEN NULL; -- Constraint existiert bereits
ELSE RAISE;
END IF;
END;
/
-- Index auf parent_document_id (idempotent)
BEGIN
EXECUTE IMMEDIATE '
CREATE INDEX dc_project_documents_parent_i ON dc_project_documents (parent_document_id)
';
EXCEPTION
WHEN OTHERS THEN
IF SQLCODE = -955 THEN NULL; -- Index existiert bereits
ELSE RAISE;
END IF;
END;
/
COMMIT;
EXIT;

View File

@@ -0,0 +1,51 @@
-- =============================================================================
-- Bereinigt umschließende Markdown-Code-Fences (```markdown ... ```) aus der
-- Spalte TRANSLATED_TEXT in DC_PROJECT_DOCUMENTS.
--
-- Hintergrund: Das Übersetzungs-Modell hat den Text mal mit, mal ohne Fence
-- gespeichert. Der Backend-Code strippt den Fence ab jetzt beim Schreiben;
-- dieses Skript bereinigt die bereits vorhandenen Altdatensätze.
--
-- Idempotent: Datensätze ohne führenden ``` bleiben unverändert.
-- =============================================================================
SET SERVEROUTPUT ON
DECLARE
v_count PLS_INTEGER := 0;
v_text CLOB;
v_nl PLS_INTEGER;
BEGIN
FOR rec IN (
SELECT id, translated_text
FROM dc_project_documents
WHERE translated_text IS NOT NULL
AND LTRIM(translated_text) LIKE '```%'
) LOOP
v_text := LTRIM(rec.translated_text);
-- Erste Zeile (z. B. ```markdown) entfernen
v_nl := INSTR(v_text, CHR(10));
IF v_nl > 0 THEN
v_text := SUBSTR(v_text, v_nl + 1);
END IF;
-- Schließendes ``` am Ende entfernen
v_text := RTRIM(v_text);
IF SUBSTR(v_text, -3) = '```' THEN
v_text := SUBSTR(v_text, 1, LENGTH(v_text) - 3);
END IF;
v_text := TRIM(v_text);
UPDATE dc_project_documents
SET translated_text = v_text
WHERE id = rec.id;
v_count := v_count + 1;
END LOOP;
COMMIT;
DBMS_OUTPUT.PUT_LINE('Bereinigte Datensätze: ' || v_count);
END;
/

View File

@@ -3,7 +3,7 @@
-- Hilfsprozeduren und -funktionen für den Dokumenten-Check
--
-- Deployment:
-- sqlplus user/pass@db @"Scripts/DC_UTILS_PKG.sql"
-- sql user/pass@db @"Scripts/DC_UTILS_PKG.sql"
-- =============================================================================
@@ -12,13 +12,24 @@
-- =============================================================================
CREATE OR REPLACE PACKAGE DC_UTILS_PKG AS
-- Absender-Adresse für Berichts-Mails (anpassen falls nötig)
c_mail_from CONSTANT VARCHAR2(100) := 'express-o@frigosped.de';
-- Basis-URL der APEX-App für Direktlinks in Mails (Friendly URL, ohne
-- Session-ID -> APEX Deep-Linking leitet bei Bedarf über den Login).
-- Die Projekt-ID wird angehängt: ...?p7_project_id=<id>
c_app_link_base CONSTANT VARCHAR2(300) :=
'https://apex-ai.express-o.org/ords/r/ai_dev/dokumentencheck/' ||
'projekt-bearbeiten?p7_project_id=';
-- -------------------------------------------------------------------------
-- Erstellt einen Markdown-Prüfbericht für ein Projekt und speichert ihn
-- in dc_projects.report_markdown.
-- in dc_projects.report_markdown. Anschließend wird der Bericht per Mail
-- an notification_email versendet (sofern gesetzt).
--
-- Aufbau des Berichts:
-- 1. Kopf (Projektname, Abschlussdatum)
-- 2. Zusammenfassung (OK / Hinweise / Abweichungen)
-- 2. Zusammenfassung (OK / Hinweise / Abweichungen) gefiltert nach notify_*
-- 3. Ergebnisse: je Dokument → Kategorie (order_nr) → Frage (order_nr)
-- Icons: ✅ OK | ⚠️ Hinweis | ❌ Abweichung | ❓ Unklar
--
@@ -27,6 +38,17 @@ CREATE OR REPLACE PACKAGE DC_UTILS_PKG AS
-- -------------------------------------------------------------------------
PROCEDURE generate_report(p_project_id IN NUMBER);
-- -------------------------------------------------------------------------
-- Versendet den gespeicherten Markdown-Bericht per E-Mail als PDF-Anhang.
-- Wird automatisch von generate_report aufgerufen; kann aber auch
-- manuell ausgelöst werden (z.B. für erneuten Versand).
-- Tut nichts wenn notification_email oder report_markdown leer ist.
-- p_skip_pdf = TRUE: kein HTTP-Rückruf ans Java-Backend (nur Text-Mail).
-- -------------------------------------------------------------------------
PROCEDURE send_report_email(p_project_id IN NUMBER,
p_skip_pdf IN BOOLEAN DEFAULT FALSE,
p_pdf IN BLOB DEFAULT NULL);
END DC_UTILS_PKG;
/
@@ -44,11 +66,24 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
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;
v_ok_cnt NUMBER := 0;
v_warn_cnt NUMBER := 0;
v_dev_cnt NUMBER := 0;
v_unkl_cnt NUMBER := 0;
v_total NUMBER := 0;
v_total_all NUMBER := 0;
-- Zähler pro Dokument
v_doc_ok_cnt NUMBER := 0;
v_doc_warn_cnt NUMBER := 0;
v_doc_dev_cnt NUMBER := 0;
v_doc_unkl_cnt NUMBER := 0;
v_doc_total NUMBER := 0; -- gefiltert (für ok_cnt-Berechnung)
v_doc_total_all NUMBER := 0; -- ungefiltert (für Gesamt-Zeile)
v_notify_ok dc_projects.notify_ok%TYPE;
v_notify_hinweis dc_projects.notify_hinweis%TYPE;
v_notify_abweichend dc_projects.notify_abweichend%TYPE;
-- Emoji-Konstanten via UNISTR (vermeidet Quellcode-Encoding-Probleme)
C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2705'); -- ✅
@@ -70,6 +105,10 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
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 ( (r.deviation = 1 AND v_notify_abweichend = 'Y')
OR (r.warning = 1 AND v_notify_hinweis = 'Y')
OR (r.deviation = 0 AND r.warning = 0 AND v_notify_ok = 'Y')
)
ORDER BY qc.order_nr, qc.name;
CURSOR c_qs(p_doc_id NUMBER, p_cat_id NUMBER) IS
@@ -85,6 +124,10 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_id
AND q.category_id = p_cat_id
AND ( (r.deviation = 1 AND v_notify_abweichend = 'Y')
OR (r.warning = 1 AND v_notify_hinweis = 'Y')
OR (r.deviation = 0 AND r.warning = 0 AND v_notify_ok = 'Y')
)
ORDER BY q.order_nr, q.id;
v_icon VARCHAR2(20 CHAR);
@@ -102,11 +145,18 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
BEGIN
DBMS_LOB.CREATETEMPORARY(v_md, TRUE);
SELECT name, status, completed_at
INTO v_name, v_status, v_done
SELECT name, status, completed_at,
notify_ok, notify_hinweis, notify_abweichend
INTO v_name, v_status, v_done,
v_notify_ok, v_notify_hinweis, v_notify_abweichend
FROM dc_projects
WHERE id = p_project_id;
SELECT COUNT(*)
INTO v_total_all
FROM dc_results
WHERE project_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'
@@ -115,7 +165,11 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
COUNT(*)
INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total
FROM dc_results
WHERE project_id = p_project_id;
WHERE project_id = p_project_id
AND ( (deviation = 1 AND v_notify_abweichend = 'Y')
OR (warning = 1 AND v_notify_hinweis = 'Y')
OR (deviation = 0 AND warning = 0 AND v_notify_ok = 'Y')
);
v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt - v_unkl_cnt;
@@ -138,13 +192,19 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
-- =====================================================================
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);
IF v_notify_ok = 'Y' THEN
ln(C_OK || ' OK: ' || v_ok_cnt);
IF v_unkl_cnt > 0 THEN
ln(C_UNKL || ' Unklar: ' || v_unkl_cnt);
END IF;
END IF;
ln('**Gesamt: ' || v_total || '**');
IF v_notify_hinweis = 'Y' THEN
ln(C_WARN || ' Hinweise: ' || v_warn_cnt);
END IF;
IF v_notify_abweichend = 'Y' THEN
ln(C_NOK || ' Abweichungen: ' || v_dev_cnt);
END IF;
ln('**Gesamt: ' || v_total_all || '**');
ln();
ln('---');
ln();
@@ -159,6 +219,42 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
ln('## ' || C_DOC || ' ' || doc.filename);
ln();
-- Zusammenfassung je Dokument (gefiltert nach notify_*, analog zur Gesamt-Zusammenfassung)
SELECT SUM(CASE WHEN deviation = 1
AND v_notify_abweichend = 'Y' THEN 1 ELSE 0 END),
SUM(CASE WHEN warning = 1 AND deviation = 0
AND v_notify_hinweis = 'Y' THEN 1 ELSE 0 END),
SUM(CASE WHEN result_type = 'UNKLAR' AND deviation = 0 AND warning = 0
AND v_notify_ok = 'Y' THEN 1 ELSE 0 END),
SUM(CASE WHEN (deviation = 1 AND v_notify_abweichend = 'Y')
OR (warning = 1 AND v_notify_hinweis = 'Y')
OR (deviation = 0 AND warning = 0
AND v_notify_ok = 'Y') THEN 1 ELSE 0 END),
COUNT(*)
INTO v_doc_dev_cnt, v_doc_warn_cnt, v_doc_unkl_cnt, v_doc_total, v_doc_total_all
FROM dc_results
WHERE project_id = p_project_id
AND doc_id = doc.id;
v_doc_ok_cnt := v_doc_total - v_doc_dev_cnt - v_doc_warn_cnt - v_doc_unkl_cnt;
IF v_notify_ok = 'Y' THEN
ln(C_OK || ' OK: ' || v_doc_ok_cnt);
IF v_doc_unkl_cnt > 0 THEN
ln(C_UNKL || ' Unklar: ' || v_doc_unkl_cnt);
END IF;
END IF;
IF v_notify_hinweis = 'Y' THEN
ln(C_WARN || ' Hinweise: ' || v_doc_warn_cnt);
END IF;
IF v_notify_abweichend = 'Y' THEN
ln(C_NOK || ' Abweichungen: ' || v_doc_dev_cnt);
END IF;
ln('**Gesamt: ' || v_doc_total_all || '**');
ln();
ln('---');
ln();
FOR cat IN c_cats(doc.id) LOOP
ln();
ln('### ' || cat.name);
@@ -172,7 +268,7 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
ELSE v_icon := C_OK;
END IF;
ln();
ln();
ln(v_icon || ' **' || q.question_text || '**');
@@ -208,12 +304,137 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
DBMS_LOB.FREETEMPORARY(v_md);
-- Mail-Versand (Fehler blockieren nie den Report-Build)
BEGIN
send_report_email(p_project_id);
EXCEPTION WHEN OTHERS THEN NULL;
END;
EXCEPTION
WHEN OTHERS THEN
DBMS_LOB.FREETEMPORARY(v_md);
RAISE;
END generate_report;
-- -------------------------------------------------------------------------
PROCEDURE send_report_email(p_project_id IN NUMBER,
p_skip_pdf IN BOOLEAN DEFAULT FALSE,
p_pdf IN BLOB DEFAULT NULL) IS
v_email dc_projects.notification_email%TYPE;
v_name dc_projects.name%TYPE;
v_markdown dc_projects.report_markdown%TYPE;
v_done dc_projects.completed_at%TYPE;
v_mail_id NUMBER;
v_pdf BLOB;
v_body VARCHAR2(4000);
v_ok_cnt NUMBER;
v_warn_cnt NUMBER;
v_dev_cnt NUMBER;
v_total_all NUMBER;
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'); -- ⚠️
CRLF CONSTANT VARCHAR2(2) := CHR(13) || CHR(10);
BEGIN
SELECT notification_email, name, report_markdown, completed_at
INTO v_email, v_name, v_markdown, v_done
FROM dc_projects
WHERE id = p_project_id;
IF v_email IS NULL OR TRIM(v_email) IS NULL THEN RETURN; END IF;
IF v_markdown IS NULL OR DBMS_LOB.GETLENGTH(v_markdown) = 0 THEN RETURN; END IF;
-- Zusammenfassung aus dc_results (alle Ergebnisse, ungefiltert)
SELECT COUNT(*),
SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN warning = 1 AND deviation = 0 THEN 1 ELSE 0 END)
INTO v_total_all, v_dev_cnt, v_warn_cnt
FROM dc_results
WHERE project_id = p_project_id;
v_ok_cnt := v_total_all - v_dev_cnt - v_warn_cnt;
-- =====================================================================
-- E-Mail-Beschreibungstext
-- =====================================================================
v_body :=
'Sehr geehrte Damen und Herren,' || CRLF ||
CRLF ||
'die automatische Dokumentenprüfung für das Projekt' || CRLF ||
'' || v_name || '"' || CRLF ||
'wurde am ' || TO_CHAR(NVL(v_done, SYSDATE), 'DD.MM.YYYY') ||
' erfolgreich abgeschlossen.' || CRLF ||
CRLF ||
'Zusammenfassung der Prüfergebnisse:' || CRLF ||
C_OK || ' OK: ' || v_ok_cnt || CRLF ||
C_WARN || ' Hinweise: ' || v_warn_cnt || CRLF ||
C_NOK || ' Abweichungen: ' || v_dev_cnt || CRLF ||
' Gesamt: ' || v_total_all || ' Prüfpunkte' || CRLF ||
CRLF ||
'Den vollständigen Prüfbericht finden Sie als PDF im Anhang.' || CRLF ||
CRLF ||
'Direkt zur Prüfung in der Anwendung:' || CRLF ||
c_app_link_base || p_project_id || CRLF ||
CRLF ||
'Mit freundlichen Grüßen' || CRLF ||
'Ihr Dokumenten-Check-System' || CRLF ||
'Frigosped Express GmbH';
-- =====================================================================
-- PDF bestimmen: Priorität p_pdf (von Java mitgeliefert) >
-- convert_markdown HTTP-Callback > kein PDF (skip_pdf oder Fehler)
-- =====================================================================
IF p_pdf IS NOT NULL AND DBMS_LOB.GETLENGTH(p_pdf) > 0 THEN
-- Java hat das PDF schon fertig mitgeschickt direkt verwenden
v_pdf := p_pdf;
ELSIF NOT p_skip_pdf THEN
BEGIN
v_pdf := DC_BACKEND_PKG.convert_markdown(v_markdown, 'PDF');
EXCEPTION WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('PDF-Generierung fehlgeschlagen: ' || SQLERRM);
v_pdf := NULL;
END;
END IF;
IF v_pdf IS NOT NULL THEN
-- Mail mit PDF-Anhang
v_mail_id := APEX_MAIL.SEND(
p_to => v_email,
p_from => c_mail_from,
p_subj => 'Prüfbericht: ' || v_name,
p_body => v_body
);
APEX_MAIL.ADD_ATTACHMENT(
p_mail_id => v_mail_id,
p_attachment => v_pdf,
p_filename => 'Pruefbericht_' ||
REGEXP_REPLACE(v_name, '[^A-Za-z0-9_äöüÄÖÜß-]', '_') ||
'.pdf',
p_mime_type => 'application/pdf'
);
ELSE
-- Fallback: Backend nicht erreichbar → Markdown direkt im Body
APEX_MAIL.SEND(
p_to => v_email,
p_from => c_mail_from,
p_subj => 'Prüfbericht: ' || v_name,
p_body => v_body || CRLF || CRLF ||
'--- Detaillierter Bericht ---' || CRLF || CRLF ||
v_markdown
);
END IF;
EXCEPTION
WHEN NO_DATA_FOUND THEN NULL;
END send_report_email;
END DC_UTILS_PKG;
/

View File

@@ -169,6 +169,15 @@ create table dc_projects (
progress number default on null 0,
completed_at date,
notification_email varchar2(100 char),
notify_ok char(1 char) default 'Y' not null
constraint dc_projects_notify_ok_ck
check (notify_ok in ('Y','N')),
notify_hinweis char(1 char) default 'Y' not null
constraint dc_projects_notify_hinweis_ck
check (notify_hinweis in ('Y','N')),
notify_abweichend char(1 char) default 'Y' not null
constraint dc_projects_notify_abweichend_ck
check (notify_abweichend in ('Y','N')),
row_version integer not null,
created date not null,
created_by varchar2(255 char) not null,
@@ -192,6 +201,10 @@ create table dc_project_documents (
references dc_document_types,
catalog_id number constraint dc_project_documents_catalog_id_fk
references dc_question_catalogs,
status varchar2(20 char) default on null 'PENDING'
constraint dc_project_documents_status_ck
check (status in ('PENDING','IN_PROGRESS','COMPLETED','ERROR')),
progress number default on null 0,
original_file blob,
original_text clob,
translated_text clob,

View File

@@ -170,7 +170,10 @@ BEGIN
p.current_phase,
p.current_doc_id,
p.current_question_id,
p.estimated_completion_at
p.estimated_completion_at,
p.notify_ok,
p.notify_hinweis,
p.notify_abweichend
FROM dc_projects p
WHERE p.id = :project_id
]'
@@ -335,12 +338,67 @@ BEGIN
p_pattern => 'projects/:project_id/complete',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt status=COMPLETED und erstellt Markdown-Bericht',
p_comments => 'Setzt status=COMPLETED, speichert Java-Markdown, sendet pro Dokument ein PDF per E-Mail',
p_source => q'[
DECLARE
v_status dc_projects.status%TYPE;
v_now DATE := SYSDATE;
v_status dc_projects.status%TYPE;
v_now DATE := SYSDATE;
v_report_md CLOB;
v_doc_pdfs JSON_ARRAY_T;
-- Konvertiert den BLOB-Request-Body (UTF-8 JSON) in einen CLOB
FUNCTION body_to_clob(p_blob IN BLOB) RETURN CLOB IS
v_clob CLOB;
v_dest INTEGER := 1;
v_src INTEGER := 1;
v_lang INTEGER := DBMS_LOB.DEFAULT_LANG_CTX;
v_warning INTEGER;
BEGIN
IF p_blob IS NULL OR DBMS_LOB.GETLENGTH(p_blob) = 0 THEN RETURN NULL; END IF;
DBMS_LOB.CREATETEMPORARY(v_clob, TRUE);
DBMS_LOB.CONVERTTOCLOB(v_clob, p_blob, DBMS_LOB.LOBMAXSIZE,
v_dest, v_src, NLS_CHARSET_ID('AL32UTF8'), v_lang, v_warning);
RETURN v_clob;
END body_to_clob;
-- Dekodiert einen Base64-CLOB in einen BLOB (chunk-weise, 24000 Zeichen pro Schritt)
FUNCTION base64_to_blob(p_b64 IN CLOB) RETURN BLOB IS
v_blob BLOB;
v_raw RAW(32767);
v_len INTEGER;
v_chunk INTEGER := 24000;
v_offset INTEGER := 1;
v_sub VARCHAR2(32767);
BEGIN
DBMS_LOB.CREATETEMPORARY(v_blob, TRUE);
v_len := DBMS_LOB.GETLENGTH(p_b64);
WHILE v_offset <= v_len LOOP
v_sub := DBMS_LOB.SUBSTR(p_b64, v_chunk, v_offset);
v_raw := UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW(v_sub));
DBMS_LOB.WRITEAPPEND(v_blob, UTL_RAW.LENGTH(v_raw), v_raw);
v_offset := v_offset + v_chunk;
END LOOP;
RETURN v_blob;
END base64_to_blob;
BEGIN
-- JSON-Body parsen: report_markdown + document_pdfs (Array)
DECLARE
v_body_clob CLOB;
v_json JSON_OBJECT_T;
BEGIN
v_body_clob := body_to_clob(:body);
IF v_body_clob IS NOT NULL THEN
v_json := JSON_OBJECT_T.parse(v_body_clob);
v_report_md := v_json.get_clob('report_markdown');
BEGIN
v_doc_pdfs := v_json.get_array('document_pdfs');
EXCEPTION WHEN OTHERS THEN v_doc_pdfs := NULL; END;
DBMS_LOB.FREETEMPORARY(v_body_clob);
END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END;
SELECT status
INTO v_status
FROM dc_projects
@@ -354,20 +412,114 @@ BEGIN
END IF;
UPDATE dc_projects
SET status = 'COMPLETED',
completed_at = v_now,
progress = 100,
current_phase = 'COMPLETED',
current_doc_id = NULL,
current_question_id = NULL,
SET status = 'COMPLETED',
completed_at = v_now,
progress = 100,
current_phase = 'COMPLETED',
current_doc_id = NULL,
current_question_id = NULL,
estimated_completion_at = NULL
WHERE id = :project_id;
-- Markdown-Bericht generieren (Fehler ignoriert, Projekt bleibt COMPLETED)
BEGIN
DC_UTILS_PKG.generate_report(:project_id);
EXCEPTION WHEN OTHERS THEN NULL;
END;
IF v_report_md IS NOT NULL AND DBMS_LOB.GETLENGTH(v_report_md) > 0 THEN
-- Kombinierten Markdown-Bericht speichern
UPDATE dc_projects SET report_markdown = v_report_md WHERE id = :project_id;
-- E-Mail mit je einem PDF-Anhang pro Dokument versenden
BEGIN
DECLARE
v_email dc_projects.notification_email%TYPE;
v_name dc_projects.name%TYPE;
v_done dc_projects.completed_at%TYPE;
v_mail_id NUMBER;
v_ok_cnt NUMBER := 0;
v_warn_cnt NUMBER := 0;
v_dev_cnt NUMBER := 0;
v_total NUMBER := 0;
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');
CRLF CONSTANT VARCHAR2(2) := CHR(13)||CHR(10);
v_body VARCHAR2(4000);
v_doc_obj JSON_OBJECT_T;
v_pdf_b64 CLOB;
v_pdf_blob BLOB;
v_fname VARCHAR2(500);
BEGIN
SELECT notification_email, name, completed_at
INTO v_email, v_name, v_done
FROM dc_projects WHERE id = :project_id;
IF v_email IS NULL OR TRIM(v_email) IS NULL THEN
NULL; -- keine Empfängeradresse → abbrechen
ELSE
SELECT COUNT(*),
SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN warning = 1 AND deviation = 0 THEN 1 ELSE 0 END)
INTO v_total, v_dev_cnt, v_warn_cnt
FROM dc_results WHERE project_id = :project_id;
v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt;
v_body :=
'Sehr geehrte Damen und Herren,' || CRLF ||
CRLF ||
'die automatische Dokumentenprüfung für das Projekt' || CRLF ||
'' || v_name || '"' || CRLF ||
'wurde am ' || TO_CHAR(NVL(v_done, SYSDATE), 'DD.MM.YYYY') ||
' erfolgreich abgeschlossen.' || CRLF ||
CRLF ||
'Zusammenfassung der Prüfergebnisse:' || CRLF ||
C_OK || ' OK: ' || v_ok_cnt || CRLF ||
C_WARN || ' Hinweise: ' || v_warn_cnt || CRLF ||
C_NOK || ' Abweichungen: ' || v_dev_cnt || CRLF ||
' Gesamt: ' || v_total || ' Prüfpunkte' || CRLF ||
CRLF ||
'Die Prüfberichte der einzelnen Dokumente finden Sie als PDF-Anhänge.' || CRLF ||
CRLF ||
'Mit freundlichen Grüßen' || CRLF ||
'Ihr Dokumenten-Check-System' || CRLF ||
'Frigosped Express GmbH';
v_mail_id := APEX_MAIL.SEND(
p_to => v_email,
p_from => DC_UTILS_PKG.c_mail_from,
p_subj => 'Prüfbericht: ' || v_name,
p_body => v_body
);
-- Einen PDF-Anhang pro Dokument hinzufügen
IF v_doc_pdfs IS NOT NULL AND v_doc_pdfs.get_size > 0 THEN
FOR i IN 0 .. v_doc_pdfs.get_size - 1 LOOP
BEGIN
v_doc_obj := TREAT(v_doc_pdfs.get(i) AS JSON_OBJECT_T);
v_fname := v_doc_obj.get_string('filename');
v_pdf_b64 := v_doc_obj.get_clob('pdf_base64');
IF v_pdf_b64 IS NOT NULL AND DBMS_LOB.GETLENGTH(v_pdf_b64) > 0 THEN
v_pdf_blob := base64_to_blob(v_pdf_b64);
APEX_MAIL.ADD_ATTACHMENT(
p_mail_id => v_mail_id,
p_attachment => v_pdf_blob,
p_filename => NVL(v_fname, 'Pruefbericht.pdf'),
p_mime_type => 'application/pdf'
);
DBMS_LOB.FREETEMPORARY(v_pdf_blob);
DBMS_LOB.FREETEMPORARY(v_pdf_b64);
END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END;
END LOOP;
END IF;
END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END;
END;
ELSE
-- Fallback: Bericht in DB generieren (z.B. bei leerem Body)
BEGIN
DC_UTILS_PKG.generate_report(:project_id);
EXCEPTION WHEN OTHERS THEN NULL;
END;
END IF;
:status_code := 200;
HTP.P('{"project_id":' || :project_id
@@ -446,6 +598,11 @@ BEGIN
d.document_type_id,
d.catalog_id,
d.uploaded_at,
d.status,
d.progress,
d.parent_document_id,
NVL(d.is_reference, 0) AS is_reference,
d.source_url,
CASE WHEN d.original_text IS NOT NULL THEN 1 ELSE 0 END AS has_original_text,
CASE WHEN d.translated_text IS NOT NULL THEN 1 ELSE 0 END AS has_translated_text,
d.row_version,
@@ -594,6 +751,67 @@ BEGIN
);
-- ===========================================================================
-- TEMPLATE 9c: documents/:doc_id/progress (PUT)
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/progress',
p_priority => 0,
p_comments => 'Dokument-Fortschritt und -Status schreiben'
);
-- 9c. PUT /api/dc/documents/:doc_id/progress
-- Body: {"progress":45,"status":"IN_PROGRESS"}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:doc_id/progress',
p_method => 'PUT',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt progress (0-100) und/oder status des Dokuments',
p_source => q'[
DECLARE
v_body CLOB;
v_progress NUMBER;
v_status VARCHAR2(20);
v_rows INTEGER;
BEGIN
v_body := :body_text;
v_progress := TO_NUMBER(JSON_VALUE(v_body, '$.progress'));
v_status := JSON_VALUE(v_body, '$.status');
IF v_progress IS NOT NULL AND (v_progress < 0 OR v_progress > 100) THEN
:status_code := 400;
HTP.P('{"error":"progress muss zwischen 0 und 100 liegen"}');
RETURN;
END IF;
UPDATE dc_project_documents
SET progress = CASE WHEN JSON_EXISTS(v_body, '$.progress') THEN v_progress ELSE progress END,
status = CASE WHEN JSON_EXISTS(v_body, '$.status') THEN v_status ELSE status END
WHERE id = :doc_id;
v_rows := SQL%ROWCOUNT;
IF v_rows = 0 THEN
:status_code := 404;
HTP.P('{"error":"Dokument nicht gefunden","doc_id":' || :doc_id || '}');
RETURN;
END IF;
:status_code := 200;
HTP.P('{"doc_id":' || :doc_id || ',"progress":' || NVL(v_progress, -1)
|| ',"status":"' || NVL(v_status, '') || '"}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 10+11: projects/:project_id/results
-- (POST = Einfuegen, DELETE = Alle loeschen)
@@ -1143,7 +1361,7 @@ BEGIN
|| ',"cost_eur":' || ROUND(NVL(v_month_eur, 0), 6)
|| ',"total_tokens":'|| NVL(v_month_tokens, 0)
|| ',"call_count":' || NVL(v_month_calls, 0)
|| '},"monthly":' || NVL(v_monthly_json, '[]')
|| '},"monthly":' || NVL(v_monthly_json, '[' || CHR(93))
|| '}'
);
@@ -1156,6 +1374,196 @@ BEGIN
);
-- ===========================================================================
-- TEMPLATE 18: documents/:parent_id/references (POST)
-- Legt eine heruntergeladene externe Referenz als neue dc_project_documents-
-- Zeile an (is_reference=1, verknuepft mit dem Originaldokument).
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:parent_id/references',
p_priority => 0,
p_comments => 'Heruntergeladene externe Referenz zu einem Dokument speichern'
);
-- 18. POST /api/dc/documents/:parent_id/references
-- Body: {"source_url":"https://...","filename":"AGB.pdf",
-- "mime_type":"application/pdf","file_base64":"JVBERi0x..."}
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'documents/:parent_id/references',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => '201 Created mit neuer doc-id; legt is_reference=1-Zeile mit BLOB an',
p_source => q'[
DECLARE
v_body_clob CLOB;
v_json JSON_OBJECT_T;
v_source_url VARCHAR2(2000);
v_filename VARCHAR2(255);
v_mime VARCHAR2(100);
v_file_b64 CLOB;
v_blob BLOB;
v_project_id NUMBER;
v_doc_type_id NUMBER;
v_new_id NUMBER;
-- Wandelt den (ggf. grossen) Request-BLOB in einen CLOB
FUNCTION body_to_clob(p_body IN BLOB) RETURN CLOB IS
v_clob CLOB;
v_dest INTEGER := 1;
v_src INTEGER := 1;
v_lang INTEGER := 0;
v_warning INTEGER := 0;
BEGIN
IF p_body IS NULL OR DBMS_LOB.GETLENGTH(p_body) = 0 THEN
RETURN NULL;
END IF;
DBMS_LOB.CREATETEMPORARY(v_clob, TRUE);
DBMS_LOB.CONVERTTOCLOB(v_clob, p_body, DBMS_LOB.LOBMAXSIZE,
v_dest, v_src, NLS_CHARSET_ID('AL32UTF8'), v_lang, v_warning);
RETURN v_clob;
END body_to_clob;
-- Dekodiert einen Base64-CLOB in einen BLOB (chunk-weise)
FUNCTION base64_to_blob(p_b64 IN CLOB) RETURN BLOB IS
v_blob BLOB;
v_raw RAW(32767);
v_chunk INTEGER := 24000;
v_offset INTEGER := 1;
v_len INTEGER;
v_sub VARCHAR2(32767);
BEGIN
DBMS_LOB.CREATETEMPORARY(v_blob, TRUE);
v_len := DBMS_LOB.GETLENGTH(p_b64);
WHILE v_offset <= v_len LOOP
v_sub := DBMS_LOB.SUBSTR(p_b64, v_chunk, v_offset);
v_raw := UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW(v_sub));
DBMS_LOB.WRITEAPPEND(v_blob, UTL_RAW.LENGTH(v_raw), v_raw);
v_offset := v_offset + v_chunk;
END LOOP;
RETURN v_blob;
END base64_to_blob;
BEGIN
v_body_clob := body_to_clob(:body);
IF v_body_clob IS NULL THEN
:status_code := 400;
HTP.P('{"error":"Leerer Request-Body"}');
RETURN;
END IF;
v_json := JSON_OBJECT_T.parse(v_body_clob);
v_source_url := v_json.get_string('source_url');
v_filename := v_json.get_string('filename');
v_mime := v_json.get_string('mime_type');
BEGIN
v_file_b64 := v_json.get_clob('file_base64');
EXCEPTION WHEN OTHERS THEN v_file_b64 := NULL; END;
DBMS_LOB.FREETEMPORARY(v_body_clob);
IF v_file_b64 IS NULL OR DBMS_LOB.GETLENGTH(v_file_b64) = 0 THEN
:status_code := 400;
HTP.P('{"error":"file_base64 ist Pflichtfeld"}');
RETURN;
END IF;
-- Projekt + Dokumenttyp vom Originaldokument uebernehmen
SELECT project_id, document_type_id
INTO v_project_id, v_doc_type_id
FROM dc_project_documents
WHERE id = :parent_id;
v_blob := base64_to_blob(v_file_b64);
INSERT INTO dc_project_documents (
project_id,
document_type_id,
parent_document_id,
is_reference,
source_url,
original_file,
mime_type,
filename,
status,
progress,
uploaded_at
) VALUES (
v_project_id,
v_doc_type_id,
:parent_id,
1,
v_source_url,
v_blob,
NVL(v_mime, 'application/octet-stream'),
NVL(v_filename, 'referenz'),
'IN_PROGRESS',
0,
SYSDATE
)
RETURNING id INTO v_new_id;
:status_code := 201;
HTP.P('{"id":' || v_new_id
|| ',"parent_document_id":' || :parent_id
|| ',"project_id":' || v_project_id || '}');
EXCEPTION
WHEN NO_DATA_FOUND THEN
ROLLBACK;
:status_code := 404;
HTP.P('{"error":"Originaldokument nicht gefunden","parent_id":' || :parent_id || '}');
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
-- ===========================================================================
-- TEMPLATE 19: projects/:project_id/references (DELETE)
-- Loescht alle heruntergeladenen Referenzen eines Projekts (Re-Processing).
-- ===========================================================================
ORDS.DEFINE_TEMPLATE(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/references',
p_priority => 0,
p_comments => 'Heruntergeladene Referenzen eines Projekts verwalten'
);
-- 19. DELETE /api/dc/projects/:project_id/references
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/references',
p_method => 'DELETE',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Loescht alle is_reference=1-Zeilen eines Projekts (fuer Re-Processing)',
p_source => q'[
DECLARE
v_deleted INTEGER;
BEGIN
DELETE FROM dc_project_documents
WHERE project_id = :project_id
AND is_reference = 1;
v_deleted := SQL%ROWCOUNT;
:status_code := 200;
HTP.P('{"project_id":' || :project_id
|| ',"deleted_count":' || v_deleted || '}');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
:status_code := 500;
HTP.P('{"error":"' || REPLACE(SQLERRM, '"', '''') || '"}');
END;
]'
);
COMMIT;
END;
/

View File

@@ -0,0 +1,217 @@
-- =============================================================================
-- Deploys only the POST /api/dc/projects/:project_id/complete handler.
-- Uses CLOB construction to avoid SQLcl bind-variable pre-processing of
-- :status_code / :body / :project_id inside the handler source string.
-- =============================================================================
DECLARE
v_src CLOB;
PROCEDURE a(p IN VARCHAR2) IS
BEGIN DBMS_LOB.APPEND(v_src, TO_CLOB(p)); END;
BEGIN
DBMS_LOB.CREATETEMPORARY(v_src, TRUE);
a(' DECLARE');
a(CHR(10)||' v_status dc_projects.status%TYPE;');
a(CHR(10)||' v_now DATE := SYSDATE;');
a(CHR(10)||' v_report_md CLOB;');
a(CHR(10)||' v_doc_pdfs JSON_ARRAY_T;');
a(CHR(10));
a(CHR(10)||' FUNCTION body_to_clob(p_blob IN BLOB) RETURN CLOB IS');
a(CHR(10)||' v_clob CLOB;');
a(CHR(10)||' v_dest INTEGER := 1;');
a(CHR(10)||' v_src INTEGER := 1;');
a(CHR(10)||' v_lang INTEGER := DBMS_LOB.DEFAULT_LANG_CTX;');
a(CHR(10)||' v_warning INTEGER;');
a(CHR(10)||' BEGIN');
a(CHR(10)||' IF p_blob IS NULL OR DBMS_LOB.GETLENGTH(p_blob) = 0 THEN RETURN NULL; END IF;');
a(CHR(10)||' DBMS_LOB.CREATETEMPORARY(v_clob, TRUE);');
a(CHR(10)||' DBMS_LOB.CONVERTTOCLOB(v_clob, p_blob, DBMS_LOB.LOBMAXSIZE,');
a(CHR(10)||' v_dest, v_src, NLS_CHARSET_ID(''AL32UTF8''), v_lang, v_warning);');
a(CHR(10)||' RETURN v_clob;');
a(CHR(10)||' END body_to_clob;');
a(CHR(10));
a(CHR(10)||' FUNCTION base64_to_blob(p_b64 IN CLOB) RETURN BLOB IS');
a(CHR(10)||' v_blob BLOB;');
a(CHR(10)||' v_raw RAW(32767);');
a(CHR(10)||' v_len INTEGER;');
a(CHR(10)||' v_chunk INTEGER := 24000;');
a(CHR(10)||' v_offset INTEGER := 1;');
a(CHR(10)||' v_sub VARCHAR2(32767);');
a(CHR(10)||' BEGIN');
a(CHR(10)||' DBMS_LOB.CREATETEMPORARY(v_blob, TRUE);');
a(CHR(10)||' v_len := DBMS_LOB.GETLENGTH(p_b64);');
a(CHR(10)||' WHILE v_offset <= v_len LOOP');
a(CHR(10)||' v_sub := DBMS_LOB.SUBSTR(p_b64, v_chunk, v_offset);');
a(CHR(10)||' v_raw := UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW(v_sub));');
a(CHR(10)||' DBMS_LOB.WRITEAPPEND(v_blob, UTL_RAW.LENGTH(v_raw), v_raw);');
a(CHR(10)||' v_offset := v_offset + v_chunk;');
a(CHR(10)||' END LOOP;');
a(CHR(10)||' RETURN v_blob;');
a(CHR(10)||' END base64_to_blob;');
a(CHR(10));
a(CHR(10)||' BEGIN');
a(CHR(10)||' DECLARE');
a(CHR(10)||' v_body_clob CLOB;');
a(CHR(10)||' v_json JSON_OBJECT_T;');
a(CHR(10)||' BEGIN');
-- Use concatenation to embed the bind variable names so SQLcl doesn't pre-process them
a(CHR(10)||' v_body_clob := body_to_clob(' || CHR(58) || 'body);');
a(CHR(10)||' IF v_body_clob IS NOT NULL THEN');
a(CHR(10)||' v_json := JSON_OBJECT_T.parse(v_body_clob);');
a(CHR(10)||' v_report_md := v_json.get_clob(''report_markdown'');');
a(CHR(10)||' BEGIN');
a(CHR(10)||' v_doc_pdfs := v_json.get_array(''document_pdfs'');');
a(CHR(10)||' EXCEPTION WHEN OTHERS THEN v_doc_pdfs := NULL; END;');
a(CHR(10)||' DBMS_LOB.FREETEMPORARY(v_body_clob);');
a(CHR(10)||' END IF;');
a(CHR(10)||' EXCEPTION WHEN OTHERS THEN NULL;');
a(CHR(10)||' END;');
a(CHR(10));
a(CHR(10)||' SELECT status');
a(CHR(10)||' INTO v_status');
a(CHR(10)||' FROM dc_projects');
a(CHR(10)||' WHERE id = ' || CHR(58) || 'project_id');
a(CHR(10)||' FOR UPDATE NOWAIT;');
a(CHR(10));
a(CHR(10)||' IF v_status = ''COMPLETED'' THEN');
a(CHR(10)||' ' || CHR(58) || 'status_code := 409;');
a(CHR(10)||' HTP.P(''{"error":"Projekt ist bereits COMPLETED","project_id":'' || ' || CHR(58) || 'project_id || ''}'');');
a(CHR(10)||' RETURN;');
a(CHR(10)||' END IF;');
a(CHR(10));
a(CHR(10)||' UPDATE dc_projects');
a(CHR(10)||' SET status = ''COMPLETED'',');
a(CHR(10)||' completed_at = v_now,');
a(CHR(10)||' progress = 100,');
a(CHR(10)||' current_phase = ''COMPLETED'',');
a(CHR(10)||' current_doc_id = NULL,');
a(CHR(10)||' current_question_id = NULL,');
a(CHR(10)||' estimated_completion_at = NULL');
a(CHR(10)||' WHERE id = ' || CHR(58) || 'project_id;');
a(CHR(10));
a(CHR(10)||' IF v_report_md IS NOT NULL AND DBMS_LOB.GETLENGTH(v_report_md) > 0 THEN');
a(CHR(10)||' UPDATE dc_projects SET report_markdown = v_report_md WHERE id = ' || CHR(58) || 'project_id;');
a(CHR(10));
a(CHR(10)||' BEGIN');
a(CHR(10)||' DECLARE');
a(CHR(10)||' v_email dc_projects.notification_email%TYPE;');
a(CHR(10)||' v_name dc_projects.name%TYPE;');
a(CHR(10)||' v_done dc_projects.completed_at%TYPE;');
a(CHR(10)||' v_mail_id NUMBER;');
a(CHR(10)||' v_ok_cnt NUMBER := 0;');
a(CHR(10)||' v_warn_cnt NUMBER := 0;');
a(CHR(10)||' v_dev_cnt NUMBER := 0;');
a(CHR(10)||' v_total NUMBER := 0;');
a(CHR(10)||' C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR(''\2705'');');
a(CHR(10)||' C_NOK CONSTANT VARCHAR2(10 CHAR) := UNISTR(''\274C'');');
a(CHR(10)||' C_WARN CONSTANT VARCHAR2(10 CHAR) := UNISTR(''\26A0\FE0F'');');
a(CHR(10)||' CRLF CONSTANT VARCHAR2(2) := CHR(13)||CHR(10);');
a(CHR(10)||' v_body VARCHAR2(4000);');
a(CHR(10)||' v_doc_obj JSON_OBJECT_T;');
a(CHR(10)||' v_pdf_b64 CLOB;');
a(CHR(10)||' v_pdf_blob BLOB;');
a(CHR(10)||' v_fname VARCHAR2(500);');
a(CHR(10)||' BEGIN');
a(CHR(10)||' SELECT notification_email, name, completed_at');
a(CHR(10)||' INTO v_email, v_name, v_done');
a(CHR(10)||' FROM dc_projects WHERE id = ' || CHR(58) || 'project_id;');
a(CHR(10));
a(CHR(10)||' IF v_email IS NOT NULL AND TRIM(v_email) IS NOT NULL THEN');
a(CHR(10)||' SELECT COUNT(*),');
a(CHR(10)||' SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),');
a(CHR(10)||' SUM(CASE WHEN warning = 1 AND deviation = 0 THEN 1 ELSE 0 END)');
a(CHR(10)||' INTO v_total, v_dev_cnt, v_warn_cnt');
a(CHR(10)||' FROM dc_results WHERE project_id = ' || CHR(58) || 'project_id;');
a(CHR(10)||' v_ok_cnt := v_total - v_dev_cnt - v_warn_cnt;');
a(CHR(10));
a(CHR(10)||' v_body :=');
a(CHR(10)||' ''Sehr geehrte Damen und Herren,'' || CRLF ||');
a(CHR(10)||' CRLF ||');
a(CHR(10)||' ''die automatische Dokumentenprüfung für das Projekt'' || CRLF ||');
a(CHR(10)||' '''' || v_name || ''"'' || CRLF ||');
a(CHR(10)||' ''wurde am '' || TO_CHAR(NVL(v_done, SYSDATE), ''DD.MM.YYYY'') ||');
a(CHR(10)||' '' erfolgreich abgeschlossen.'' || CRLF ||');
a(CHR(10)||' CRLF ||');
a(CHR(10)||' ''Zusammenfassung der Prüfergebnisse:'' || CRLF ||');
a(CHR(10)||' C_OK || '' OK: '' || v_ok_cnt || CRLF ||');
a(CHR(10)||' C_WARN || '' Hinweise: '' || v_warn_cnt || CRLF ||');
a(CHR(10)||' C_NOK || '' Abweichungen: '' || v_dev_cnt || CRLF ||');
a(CHR(10)||' '' Gesamt: '' || v_total || '' Prüfpunkte'' || CRLF ||');
a(CHR(10)||' CRLF ||');
a(CHR(10)||' ''Die Prüfberichte der einzelnen Dokumente finden Sie als PDF-Anhänge.'' || CRLF ||');
a(CHR(10)||' CRLF ||');
a(CHR(10)||' ''Mit freundlichen Grüßen'' || CRLF ||');
a(CHR(10)||' ''Ihr Dokumenten-Check-System'' || CRLF ||');
a(CHR(10)||' ''Frigosped Express GmbH'';');
a(CHR(10));
a(CHR(10)||' v_mail_id := APEX_MAIL.SEND(');
a(CHR(10)||' p_to => v_email,');
a(CHR(10)||' p_from => DC_UTILS_PKG.c_mail_from,');
a(CHR(10)||' p_subj => ''Prüfbericht: '' || v_name,');
a(CHR(10)||' p_body => v_body');
a(CHR(10)||' );');
a(CHR(10));
a(CHR(10)||' IF v_doc_pdfs IS NOT NULL AND v_doc_pdfs.get_size > 0 THEN');
a(CHR(10)||' FOR i IN 0 .. v_doc_pdfs.get_size - 1 LOOP');
a(CHR(10)||' BEGIN');
a(CHR(10)||' v_doc_obj := TREAT(v_doc_pdfs.get(i) AS JSON_OBJECT_T);');
a(CHR(10)||' v_fname := v_doc_obj.get_string(''filename'');');
a(CHR(10)||' v_pdf_b64 := v_doc_obj.get_clob(''pdf_base64'');');
a(CHR(10)||' IF v_pdf_b64 IS NOT NULL AND DBMS_LOB.GETLENGTH(v_pdf_b64) > 0 THEN');
a(CHR(10)||' v_pdf_blob := base64_to_blob(v_pdf_b64);');
a(CHR(10)||' APEX_MAIL.ADD_ATTACHMENT(');
a(CHR(10)||' p_mail_id => v_mail_id,');
a(CHR(10)||' p_attachment => v_pdf_blob,');
a(CHR(10)||' p_filename => NVL(v_fname, ''Pruefbericht.pdf''),');
a(CHR(10)||' p_mime_type => ''application/pdf''');
a(CHR(10)||' );');
a(CHR(10)||' DBMS_LOB.FREETEMPORARY(v_pdf_blob);');
a(CHR(10)||' DBMS_LOB.FREETEMPORARY(v_pdf_b64);');
a(CHR(10)||' END IF;');
a(CHR(10)||' EXCEPTION WHEN OTHERS THEN NULL;');
a(CHR(10)||' END;');
a(CHR(10)||' END LOOP;');
a(CHR(10)||' END IF;');
a(CHR(10)||' END IF;');
a(CHR(10)||' EXCEPTION WHEN OTHERS THEN NULL;');
a(CHR(10)||' END;');
a(CHR(10)||' END;');
a(CHR(10)||' ELSE');
a(CHR(10)||' BEGIN');
a(CHR(10)||' DC_UTILS_PKG.generate_report(' || CHR(58) || 'project_id);');
a(CHR(10)||' EXCEPTION WHEN OTHERS THEN NULL;');
a(CHR(10)||' END;');
a(CHR(10)||' END IF;');
a(CHR(10));
a(CHR(10)||' ' || CHR(58) || 'status_code := 200;');
a(CHR(10)||' HTP.P(''{"project_id":'' || ' || CHR(58) || 'project_id');
a(CHR(10)||' || '',\"status\":\"COMPLETED\"''');
a(CHR(10)||' || '',\"completed_at\":\"'' || TO_CHAR(v_now, ''YYYY-MM-DD"T"HH24:MI:SS'') || ''\"''');
a(CHR(10)||' || ''}'');');
a(CHR(10));
a(CHR(10)||' EXCEPTION');
a(CHR(10)||' WHEN NO_DATA_FOUND THEN');
a(CHR(10)||' ' || CHR(58) || 'status_code := 404;');
a(CHR(10)||' HTP.P(''{"error":"Projekt nicht gefunden","project_id":'' || ' || CHR(58) || 'project_id || ''}'');');
a(CHR(10)||' WHEN OTHERS THEN');
a(CHR(10)||' ROLLBACK;');
a(CHR(10)||' ' || CHR(58) || 'status_code := 500;');
a(CHR(10)||' HTP.P(''{"error":"'' || REPLACE(SQLERRM, ''"'', '''''''') || ''"}'');');
a(CHR(10)||' END;');
ORDS.DEFINE_HANDLER(
p_module_name => 'frigosped.dc',
p_pattern => 'projects/:project_id/complete',
p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL,
p_comments => 'Setzt status=COMPLETED, sendet pro Dokument ein PDF per E-Mail',
p_source => v_src
);
DBMS_LOB.FREETEMPORARY(v_src);
COMMIT;
DBMS_OUTPUT.PUT_LINE('Handler erfolgreich deployed.');
END;
/