Compare commits

1 Commits

Author SHA1 Message Date
c75f460ec5 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>
2026-06-22 12:45:55 +02:00
91 changed files with 3949 additions and 255 deletions

View File

@@ -42,7 +42,12 @@
"Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl apply *)", "Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl apply *)",
"Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl rollout restart deployment/dc-backend -n ai-env)", "Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl rollout restart deployment/dc-backend -n ai-env)",
"Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl rollout status deployment/dc-backend -n ai-env --timeout=120s)", "Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl rollout status deployment/dc-backend -n ai-env --timeout=120s)",
"Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl logs *)" "Bash(KUBECONFIG=../env/frigo-dev.yaml kubectl logs *)",
"WebSearch",
"WebFetch(domain:docs.langchain4j.dev)",
"WebFetch(domain:mvnrepository.com)",
"WebFetch(domain:github.com)",
"WebFetch(domain:docs.mistral.ai)"
] ]
} }
} }

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/Dokumenten-Check.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

18
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="dc-backend" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="dc-backend" options="-parameters" />
</option>
</component>
</project>

7
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/dc-backend/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/dc-backend/src/main/resources" charset="UTF-8" />
</component>
</project>

20
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

15
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="KubernetesApiProvider">{}</component>
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/dc-backend/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21 (WSL)" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Dokumenten-Check.iml" filepath="$PROJECT_DIR$/.idea/Dokumenten-Check.iml" />
</modules>
</component>
</project>

23
.idea/remote-targets.xml generated Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteTargetsManager">
<targets>
<target name="WSL" type="wsl" uuid="46ff7e89-7c31-4912-b259-de4f83198430">
<config>
<option name="distributionMsId" value="Ubuntu" />
</config>
<ContributedStateBase type="JavaLanguageRuntime">
<config>
<option name="homePath" value="$PROJECT_DIR$/../../../../usr/lib/jvm/default-java" />
</config>
</ContributedStateBase>
<ContributedStateBase type="MavenRuntime">
<config>
<option name="homePath" value="/usr/share/maven" />
<option name="versionString" value="3.8.7" />
</config>
</ContributedStateBase>
</target>
</targets>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -8,10 +8,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Database Setup ## Database Setup
> **Tool:** Always use `sql` (SQLcl), never `sqlplus`.
```bash ```bash
# Connect to Oracle DB and run scripts in order: # Connect to Oracle DB and run scripts in order:
sqlplus user/pass@db @"Scripts/Datenmodell DocumentCheck.sql" sql user/pass@db @"Scripts/Datenmodell DocumentCheck.sql"
sqlplus user/pass@db @"Scripts/Fragenkatalog-AGB anlegen.sql" sql user/pass@db @"Scripts/Fragenkatalog-AGB anlegen.sql"
``` ```
No build/compilation step — APEX applications deploy directly to Oracle instances. No build/compilation step — APEX applications deploy directly to Oracle instances.

View File

@@ -52,12 +52,28 @@ CREATE OR REPLACE PACKAGE DC_BACKEND_PKG AS
-- ------------------------------------------------------------------------- -- -------------------------------------------------------------------------
FUNCTION health_check RETURN BOOLEAN; 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. -- Startet die Prüfung und wirft eine Exception wenn nicht 202 zurückkommt.
-- Convenience-Wrapper für direkte APEX-Button-Aufrufe. -- 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( 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 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) -- 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( PROCEDURE start_check_or_raise(
p_project_id IN NUMBER p_project_id IN NUMBER,
p_reset IN BOOLEAN DEFAULT FALSE
) IS ) IS
l_status NUMBER; l_status NUMBER;
l_response VARCHAR2(4000); l_response VARCHAR2(4000);
BEGIN BEGIN
IF p_reset THEN
reset_project(p_project_id);
END IF;
start_check( start_check(
p_project_id => p_project_id, p_project_id => p_project_id,
p_status_code => l_status, 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 -- Hilfsprozeduren und -funktionen für den Dokumenten-Check
-- --
-- Deployment: -- 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 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 -- 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: -- Aufbau des Berichts:
-- 1. Kopf (Projektname, Abschlussdatum) -- 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) -- 3. Ergebnisse: je Dokument → Kategorie (order_nr) → Frage (order_nr)
-- Icons: ✅ OK | ⚠️ Hinweis | ❌ Abweichung | ❓ Unklar -- 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); 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; END DC_UTILS_PKG;
/ /
@@ -44,11 +66,24 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
v_status dc_projects.status%TYPE; v_status dc_projects.status%TYPE;
v_done dc_projects.completed_at%TYPE; v_done dc_projects.completed_at%TYPE;
v_ok_cnt NUMBER := 0; v_ok_cnt NUMBER := 0;
v_warn_cnt NUMBER := 0; v_warn_cnt NUMBER := 0;
v_dev_cnt NUMBER := 0; v_dev_cnt NUMBER := 0;
v_unkl_cnt NUMBER := 0; v_unkl_cnt NUMBER := 0;
v_total 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) -- Emoji-Konstanten via UNISTR (vermeidet Quellcode-Encoding-Probleme)
C_OK CONSTANT VARCHAR2(10 CHAR) := UNISTR('\2705'); -- ✅ 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 JOIN dc_results r ON r.question_id = q.id
WHERE r.project_id = p_project_id WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_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; ORDER BY qc.order_nr, qc.name;
CURSOR c_qs(p_doc_id NUMBER, p_cat_id NUMBER) IS 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 WHERE r.project_id = p_project_id
AND r.doc_id = p_doc_id AND r.doc_id = p_doc_id
AND q.category_id = p_cat_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; ORDER BY q.order_nr, q.id;
v_icon VARCHAR2(20 CHAR); v_icon VARCHAR2(20 CHAR);
@@ -102,11 +145,18 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
BEGIN BEGIN
DBMS_LOB.CREATETEMPORARY(v_md, TRUE); DBMS_LOB.CREATETEMPORARY(v_md, TRUE);
SELECT name, status, completed_at SELECT name, status, completed_at,
INTO v_name, v_status, v_done 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 FROM dc_projects
WHERE id = p_project_id; 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), SELECT SUM(CASE WHEN deviation = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN warning = 1 THEN 1 ELSE 0 END), SUM(CASE WHEN warning = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN result_type = 'UNKLAR' SUM(CASE WHEN result_type = 'UNKLAR'
@@ -115,7 +165,11 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
COUNT(*) COUNT(*)
INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total INTO v_dev_cnt, v_warn_cnt, v_unkl_cnt, v_total
FROM dc_results 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; 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('## Zusammenfassung');
ln(); ln();
ln(C_OK || ' OK: ' || v_ok_cnt); IF v_notify_ok = 'Y' THEN
ln(C_WARN || ' Hinweise: ' || v_warn_cnt); ln(C_OK || ' OK: ' || v_ok_cnt);
ln(C_NOK || ' Abweichungen: ' || v_dev_cnt); IF v_unkl_cnt > 0 THEN
IF v_unkl_cnt > 0 THEN ln(C_UNKL || ' Unklar: ' || v_unkl_cnt);
ln(C_UNKL || ' Unklar: ' || v_unkl_cnt); END IF;
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('---'); ln('---');
ln(); ln();
@@ -159,6 +219,42 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
ln('## ' || C_DOC || ' ' || doc.filename); ln('## ' || C_DOC || ' ' || doc.filename);
ln(); 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 FOR cat IN c_cats(doc.id) LOOP
ln(); ln();
ln('### ' || cat.name); ln('### ' || cat.name);
@@ -208,12 +304,137 @@ CREATE OR REPLACE PACKAGE BODY DC_UTILS_PKG AS
DBMS_LOB.FREETEMPORARY(v_md); 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 EXCEPTION
WHEN OTHERS THEN WHEN OTHERS THEN
DBMS_LOB.FREETEMPORARY(v_md); DBMS_LOB.FREETEMPORARY(v_md);
RAISE; RAISE;
END generate_report; 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; END DC_UTILS_PKG;
/ /

View File

@@ -169,6 +169,15 @@ create table dc_projects (
progress number default on null 0, progress number default on null 0,
completed_at date, completed_at date,
notification_email varchar2(100 char), 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, row_version integer not null,
created date not null, created date not null,
created_by varchar2(255 char) not null, created_by varchar2(255 char) not null,
@@ -192,6 +201,10 @@ create table dc_project_documents (
references dc_document_types, references dc_document_types,
catalog_id number constraint dc_project_documents_catalog_id_fk catalog_id number constraint dc_project_documents_catalog_id_fk
references dc_question_catalogs, 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_file blob,
original_text clob, original_text clob,
translated_text clob, translated_text clob,

View File

@@ -170,7 +170,10 @@ BEGIN
p.current_phase, p.current_phase,
p.current_doc_id, p.current_doc_id,
p.current_question_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 FROM dc_projects p
WHERE p.id = :project_id WHERE p.id = :project_id
]' ]'
@@ -335,12 +338,67 @@ BEGIN
p_pattern => 'projects/:project_id/complete', p_pattern => 'projects/:project_id/complete',
p_method => 'POST', p_method => 'POST',
p_source_type => ORDS.SOURCE_TYPE_PLSQL, 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'[ p_source => q'[
DECLARE DECLARE
v_status dc_projects.status%TYPE; v_status dc_projects.status%TYPE;
v_now DATE := SYSDATE; 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 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 SELECT status
INTO v_status INTO v_status
FROM dc_projects FROM dc_projects
@@ -354,20 +412,114 @@ BEGIN
END IF; END IF;
UPDATE dc_projects UPDATE dc_projects
SET status = 'COMPLETED', SET status = 'COMPLETED',
completed_at = v_now, completed_at = v_now,
progress = 100, progress = 100,
current_phase = 'COMPLETED', current_phase = 'COMPLETED',
current_doc_id = NULL, current_doc_id = NULL,
current_question_id = NULL, current_question_id = NULL,
estimated_completion_at = NULL estimated_completion_at = NULL
WHERE id = :project_id; WHERE id = :project_id;
-- Markdown-Bericht generieren (Fehler ignoriert, Projekt bleibt COMPLETED) IF v_report_md IS NOT NULL AND DBMS_LOB.GETLENGTH(v_report_md) > 0 THEN
BEGIN -- Kombinierten Markdown-Bericht speichern
DC_UTILS_PKG.generate_report(:project_id); UPDATE dc_projects SET report_markdown = v_report_md WHERE id = :project_id;
EXCEPTION WHEN OTHERS THEN NULL;
END; -- 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; :status_code := 200;
HTP.P('{"project_id":' || :project_id HTP.P('{"project_id":' || :project_id
@@ -446,6 +598,11 @@ BEGIN
d.document_type_id, d.document_type_id,
d.catalog_id, d.catalog_id,
d.uploaded_at, 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.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, CASE WHEN d.translated_text IS NOT NULL THEN 1 ELSE 0 END AS has_translated_text,
d.row_version, 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 -- TEMPLATE 10+11: projects/:project_id/results
-- (POST = Einfuegen, DELETE = Alle loeschen) -- (POST = Einfuegen, DELETE = Alle loeschen)
@@ -1143,7 +1361,7 @@ BEGIN
|| ',"cost_eur":' || ROUND(NVL(v_month_eur, 0), 6) || ',"cost_eur":' || ROUND(NVL(v_month_eur, 0), 6)
|| ',"total_tokens":'|| NVL(v_month_tokens, 0) || ',"total_tokens":'|| NVL(v_month_tokens, 0)
|| ',"call_count":' || NVL(v_month_calls, 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; COMMIT;
END; 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;
/

View File

@@ -1,7 +1,7 @@
# ============================================================================= # =============================================================================
# Stage 1: Build # Stage 1: Build
# ============================================================================= # =============================================================================
FROM maven:3.9-eclipse-temurin-21 AS build FROM docker.io/library/maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build WORKDIR /build
@@ -15,7 +15,7 @@ RUN mvn package -DskipTests -q
# ============================================================================= # =============================================================================
# Stage 2: Runtime # Stage 2: Runtime
# ============================================================================= # =============================================================================
FROM eclipse-temurin:21-jre FROM docker.io/library/eclipse-temurin:21-jre
WORKDIR /app WORKDIR /app

0
dc-backend/build-and-push.sh Normal file → Executable file
View File

20
dc-backend/follow_logs.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(pwd)"
export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml"
POD=$(kubectl get pod -n ai-env -l app=dc-backend \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)
if [ -z "$POD" ]; then
echo "Kein laufender dc-backend Pod gefunden. Alle Pods:"
kubectl get pods -n ai-env -l app=dc-backend
exit 1
fi
echo "=== Logs: $POD ==="
kubectl logs "$POD" -n ai-env --tail $1
while true; do kubectl logs -f "$POD" -n ai-env --tail 0 && break || sleep 2; done
kubectl logs -f "$POD" -n ai-env --tail $1

0
dc-backend/getPods.sh Normal file → Executable file
View File

View File

@@ -66,10 +66,10 @@ spec:
periodSeconds: 10 periodSeconds: 10
resources: resources:
requests: requests:
memory: "256Mi" memory: "512Mi"
cpu: "100m" cpu: "100m"
limits: limits:
memory: "512Mi" memory: "2Gi"
cpu: "500m" cpu: "500m"
--- ---
apiVersion: v1 apiVersion: v1

0
dc-backend/logs.sh Normal file → Executable file
View File

174
dc-backend/pod-ram-usage.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# pod-ram-usage.sh — show RAM usage by pod, with optional namespace / sorting flags
#
# Usage:
# ./pod-ram-usage.sh # all namespaces
# ./pod-ram-usage.sh -n my-namespace # specific namespace
# ./pod-ram-usage.sh -s # sort by usage (highest first)
# ./pod-ram-usage.sh -n kube-system -s # combine flags
# ./pod-ram-usage.sh -w 5 # watch mode, refresh every 5 seconds
# ./pod-ram-usage.sh -h # show help
set -euo pipefail
# ── defaults ────────────────────────────────────────────────────────────────
NAMESPACE=""
SORT=false
WATCH_INTERVAL=0 # 0 = run once
# ── helpers ─────────────────────────────────────────────────────────────────
usage() {
grep '^#' "$0" | grep -v '#!/' | sed 's/^# \{0,2\}//'
exit 0
}
die() { echo "ERROR: $*" >&2; exit 1; }
check_deps() {
command -v kubectl >/dev/null 2>&1 || die "kubectl not found in PATH"
# metrics-server check (soft): warn rather than abort
if ! kubectl top pod --all-namespaces >/dev/null 2>&1; then
echo "WARNING: 'kubectl top' failed — is metrics-server installed and ready?" >&2
echo " Install it with:" >&2
echo " kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml" >&2
exit 1
fi
}
# Convert Mi/Ki/Gi strings to MiB float for sorting
to_mib() {
local val="$1"
if [[ "$val" =~ ^([0-9]+(\.[0-9]+)?)Gi$ ]]; then echo "${BASH_REMATCH[1]} * 1024" | bc
elif [[ "$val" =~ ^([0-9]+(\.[0-9]+)?)Mi$ ]]; then echo "${BASH_REMATCH[1]}"
elif [[ "$val" =~ ^([0-9]+(\.[0-9]+)?)Ki$ ]]; then echo "scale=2; ${BASH_REMATCH[1]} / 1024" | bc
elif [[ "$val" =~ ^([0-9]+(\.[0-9]+)?)m$ ]]; then echo "0" # millicores — shouldn't appear for RAM
else echo "0"
fi
}
# ── parse args ───────────────────────────────────────────────────────────────
while getopts ":n:sw:h" opt; do
case $opt in
n) NAMESPACE="$OPTARG" ;;
s) SORT=true ;;
w) WATCH_INTERVAL="$OPTARG" ;;
h) usage ;;
:) die "Option -$OPTARG requires an argument." ;;
\?) die "Unknown option -$OPTARG" ;;
esac
done
# ── main logic ───────────────────────────────────────────────────────────────
run_once() {
# build kubectl top args
local top_args=("top" "pod")
if [[ -n "$NAMESPACE" ]]; then
top_args+=("-n" "$NAMESPACE")
else
top_args+=("--all-namespaces")
fi
top_args+=("--no-headers")
# fetch raw data
local raw
raw=$(kubectl "${top_args[@]}" 2>/dev/null) || {
echo "ERROR: kubectl top pod failed." >&2; return 1
}
# also get resource requests/limits for context
local req_args=("get" "pods")
if [[ -n "$NAMESPACE" ]]; then
req_args+=("-n" "$NAMESPACE")
else
req_args+=("--all-namespaces")
fi
req_args+=("-o" "custom-columns=\
NS:.metadata.namespace,\
NAME:.metadata.name,\
MEM_REQ:.spec.containers[*].resources.requests.memory,\
MEM_LIM:.spec.containers[*].resources.limits.memory" \
"--no-headers")
local limits
limits=$(kubectl "${req_args[@]}" 2>/dev/null) || limits=""
# ── header ──────────────────────────────────────────────────────────────
local ns_label="ALL NAMESPACES"
[[ -n "$NAMESPACE" ]] && ns_label="$NAMESPACE"
echo ""
echo "═══════════════════════════════════════════════════════════════════════"
printf " RAM Usage by Pod — namespace: %-35s\n" "$ns_label"
printf " %s\n" "$(date '+%Y-%m-%d %H:%M:%S')"
echo "═══════════════════════════════════════════════════════════════════════"
printf "%-20s %-42s %10s %10s %10s\n" \
"NAMESPACE" "POD" "MEMORY" "REQUEST" "LIMIT"
echo "───────────────────────────────────────────────────────────────────────"
# ── build output rows ────────────────────────────────────────────────────
declare -a rows
while IFS= read -r line; do
[[ -z "$line" ]] && continue
# kubectl top --all-namespaces: NAMESPACE POD CPU MEM
# kubectl top -n NS: POD CPU MEM
if [[ -n "$NAMESPACE" ]]; then
local pod cpu mem
read -r pod cpu mem <<< "$line"
local ns="$NAMESPACE"
else
local ns pod cpu mem
read -r ns pod cpu mem <<< "$line"
fi
# look up request / limit from the limits table
local req lim
req=$(echo "$limits" | awk -v n="$ns" -v p="$pod" '$1==n && $2==p {print $3}' | head -1)
lim=$(echo "$limits" | awk -v n="$ns" -v p="$pod" '$1==n && $2==p {print $4}' | head -1)
[[ -z "$req" || "$req" == "<none>" ]] && req="—"
[[ -z "$lim" || "$lim" == "<none>" ]] && lim="—"
# for sort key convert mem to MiB numeric
local sort_key
sort_key=$(to_mib "$mem")
rows+=("$(printf '%020.4f\t%-20s\t%-42s\t%10s\t%10s\t%10s' \
"$sort_key" "$ns" "$pod" "$mem" "$req" "$lim")")
done <<< "$raw"
# ── sort if requested ────────────────────────────────────────────────────
if $SORT; then
IFS=$'\n' sorted=($(printf '%s\n' "${rows[@]}" | sort -t$'\t' -k1 -rn))
else
IFS=$'\n' sorted=("${rows[@]}")
fi
# ── print rows ───────────────────────────────────────────────────────────
local total_mib=0
local count=0
for row in "${sorted[@]}"; do
local sort_key ns pod mem req lim
IFS=$'\t' read -r sort_key ns pod mem req lim <<< "$row"
printf "%-20s %-42s %10s %10s %10s\n" "$ns" "$pod" "$mem" "$req" "$lim"
total_mib=$(echo "$total_mib + $sort_key" | bc)
(( count++ )) || true
done
echo "───────────────────────────────────────────────────────────────────────"
printf " %d pod(s) Total usage: %.0f MiB\n" "$count" "$total_mib"
echo "═══════════════════════════════════════════════════════════════════════"
echo ""
}
# ── entry point ──────────────────────────────────────────────────────────────
check_deps
if [[ "$WATCH_INTERVAL" -gt 0 ]]; then
echo "Watch mode — refreshing every ${WATCH_INTERVAL}s (Ctrl-C to stop)"
while true; do
clear
run_once
sleep "$WATCH_INTERVAL"
done
else
run_once
fi

View File

@@ -90,6 +90,13 @@
<version>3.0.3</version> <version>3.0.3</version>
</dependency> </dependency>
<!-- Markdown → PDF Export -->
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>1.3.43</version>
</dependency>
<!-- DOCX → Markdown --> <!-- DOCX → Markdown -->
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
@@ -104,6 +111,13 @@
<version>0.12.0</version> <version>0.12.0</version>
</dependency> </dependency>
<!-- HTML → Text (heruntergeladene Referenz-Webseiten) -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
<!-- ================================================================ --> <!-- ================================================================ -->
<!-- Tests --> <!-- Tests -->
<!-- ================================================================ --> <!-- ================================================================ -->

View File

@@ -0,0 +1,19 @@
package de.frigosped.dc.client;
import de.frigosped.dc.model.MistralOcrRequest;
import de.frigosped.dc.model.MistralOcrResponse;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "mistral-ocr")
@Path("/v1")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface MistralOcrClient {
@POST
@Path("/ocr")
MistralOcrResponse ocr(@HeaderParam("Authorization") String authorization,
MistralOcrRequest request);
}

View File

@@ -116,10 +116,12 @@ public interface OrdsClient {
/** /**
* POST /api/dc/projects/:projectId/complete * POST /api/dc/projects/:projectId/complete
* Setzt Status auf COMPLETED + completed_at = SYSDATE. * Setzt Status auf COMPLETED + completed_at = SYSDATE.
* Body enthält optional das vorberechnete report_markdown.
*/ */
@POST @POST
@Path("/projects/{projectId}/complete") @Path("/projects/{projectId}/complete")
Response completeProject(@PathParam("projectId") long projectId); Response completeProject(@PathParam("projectId") long projectId,
CompleteProjectRequest body);
// ========================================================================= // =========================================================================
// Dokumente // Dokumente
@@ -159,6 +161,31 @@ public interface OrdsClient {
@Path("/documents/{docId}/texts") @Path("/documents/{docId}/texts")
Response updateTexts(@PathParam("docId") long docId, TextsRequest body); Response updateTexts(@PathParam("docId") long docId, TextsRequest body);
/**
* PUT /api/dc/documents/:docId/progress
* Setzt status und/oder progress (0-100) eines Dokuments.
*/
@PUT
@Path("/documents/{docId}/progress")
Response updateDocumentProgress(@PathParam("docId") long docId, DocumentProgressRequest body);
/**
* POST /api/dc/documents/:parentId/references
* Legt eine heruntergeladene externe Referenz als neue dc_project_documents-Zeile an.
* Antwort: 201 Created, Body {"id": <new_id>, ...}
*/
@POST
@Path("/documents/{parentId}/references")
Response insertReference(@PathParam("parentId") long parentId, ReferenceCreateRequest body);
/**
* DELETE /api/dc/projects/:projectId/references
* Löscht alle heruntergeladenen Referenzen eines Projekts (vor Re-Processing).
*/
@DELETE
@Path("/projects/{projectId}/references")
Response deleteReferences(@PathParam("projectId") long projectId);
// ========================================================================= // =========================================================================
// Ergebnisse // Ergebnisse
// ========================================================================= // =========================================================================

View File

@@ -47,6 +47,8 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
LOG.infof("=== ORDS %s %s END ===", requestCtx.getMethod(), requestCtx.getUri()); LOG.infof("=== ORDS %s %s END ===", requestCtx.getMethod(), requestCtx.getUri());
} }
private static final int MAX_BODY_LOG = 500;
private String readRequest(ClientRequestContext ctx) { private String readRequest(ClientRequestContext ctx) {
Object entity = ctx.getEntity(); Object entity = ctx.getEntity();
if (entity == null) return null; if (entity == null) return null;
@@ -63,7 +65,11 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
} else { } else {
body = entity.toString(); body = entity.toString();
} }
return body.isBlank() ? null : body; if (body.isBlank()) return null;
if (body.length() > MAX_BODY_LOG) {
return body.substring(0, MAX_BODY_LOG) + String.format(" ... [%d Zeichen gesamt]", body.length());
}
return body;
} }
private String readResponse(ClientResponseContext ctx) throws IOException { private String readResponse(ClientResponseContext ctx) throws IOException {

View File

@@ -0,0 +1,22 @@
package de.frigosped.dc.model;
import java.util.List;
public class CompleteProjectRequest {
private String reportMarkdown;
private List<DocumentPdfEntry> documentPdfs;
public CompleteProjectRequest() {}
public CompleteProjectRequest(String reportMarkdown, List<DocumentPdfEntry> documentPdfs) {
this.reportMarkdown = reportMarkdown;
this.documentPdfs = documentPdfs;
}
public String getReportMarkdown() { return reportMarkdown; }
public void setReportMarkdown(String v) { this.reportMarkdown = v; }
public List<DocumentPdfEntry> getDocumentPdfs() { return documentPdfs; }
public void setDocumentPdfs(List<DocumentPdfEntry> v) { this.documentPdfs = v; }
}

View File

@@ -0,0 +1,20 @@
package de.frigosped.dc.model;
public class DocumentPdfEntry {
private String filename;
private String pdfBase64;
public DocumentPdfEntry() {}
public DocumentPdfEntry(String filename, String pdfBase64) {
this.filename = filename;
this.pdfBase64 = pdfBase64;
}
public String getFilename() { return filename; }
public void setFilename(String v) { this.filename = v; }
public String getPdfBase64() { return pdfBase64; }
public void setPdfBase64(String v) { this.pdfBase64 = v; }
}

View File

@@ -0,0 +1,18 @@
package de.frigosped.dc.model;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class DocumentProgressRequest {
private Integer progress;
private String status;
public DocumentProgressRequest(Integer progress, String status) {
this.progress = progress;
this.status = status;
}
public Integer getProgress() { return progress; }
public String getStatus() { return status; }
}

View File

@@ -0,0 +1,28 @@
package de.frigosped.dc.model;
public class MistralOcrRequest {
private String model;
private Document document;
public MistralOcrRequest(String model, Document document) {
this.model = model;
this.document = document;
}
public String getModel() { return model; }
public Document getDocument() { return document; }
public static class Document {
private String type;
private String documentUrl;
public Document(String type, String documentUrl) {
this.type = type;
this.documentUrl = documentUrl;
}
public String getType() { return type; }
public String getDocumentUrl() { return documentUrl; }
}
}

View File

@@ -0,0 +1,37 @@
package de.frigosped.dc.model;
import java.util.List;
public class MistralOcrResponse {
private List<Page> pages;
private UsageInfo usageInfo;
public List<Page> getPages() { return pages; }
public void setPages(List<Page> pages) { this.pages = pages; }
public UsageInfo getUsageInfo() { return usageInfo; }
public void setUsageInfo(UsageInfo usageInfo) { this.usageInfo = usageInfo; }
public static class Page {
private int index;
private String markdown;
public int getIndex() { return index; }
public void setIndex(int index) { this.index = index; }
public String getMarkdown() { return markdown; }
public void setMarkdown(String markdown) { this.markdown = markdown; }
}
public static class UsageInfo {
private int pagesProcessed;
private Long docSizeBytes;
public int getPagesProcessed() { return pagesProcessed; }
public void setPagesProcessed(int pagesProcessed) { this.pagesProcessed = pagesProcessed; }
public Long getDocSizeBytes() { return docSizeBytes; }
public void setDocSizeBytes(Long docSizeBytes) { this.docSizeBytes = docSizeBytes; }
}
}

View File

@@ -14,8 +14,13 @@ public class OrdsDocument {
private Long documentTypeId; private Long documentTypeId;
private Long catalogId; private Long catalogId;
private LocalDateTime uploadedAt; private LocalDateTime uploadedAt;
private String status;
private Integer progress;
private Integer hasOriginalText; // 0 oder 1 private Integer hasOriginalText; // 0 oder 1
private Integer hasTranslatedText; // 0 oder 1 private Integer hasTranslatedText; // 0 oder 1
private Long parentDocumentId; // gesetzt bei heruntergeladenen Referenzen
private Integer isReference; // 0 = Originaldokument, 1 = Referenz
private String sourceUrl; // Ursprungs-URL der Referenz
private Integer rowVersion; private Integer rowVersion;
private LocalDateTime created; private LocalDateTime created;
private LocalDateTime updated; private LocalDateTime updated;
@@ -40,12 +45,30 @@ public class OrdsDocument {
public LocalDateTime getUploadedAt() { return uploadedAt; } public LocalDateTime getUploadedAt() { return uploadedAt; }
public void setUploadedAt(LocalDateTime v) { this.uploadedAt = v; } public void setUploadedAt(LocalDateTime v) { this.uploadedAt = v; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Integer getProgress() { return progress; }
public void setProgress(Integer progress) { this.progress = progress; }
public Integer getHasOriginalText() { return hasOriginalText; } public Integer getHasOriginalText() { return hasOriginalText; }
public void setHasOriginalText(Integer v) { this.hasOriginalText = v; } public void setHasOriginalText(Integer v) { this.hasOriginalText = v; }
public Integer getHasTranslatedText() { return hasTranslatedText; } public Integer getHasTranslatedText() { return hasTranslatedText; }
public void setHasTranslatedText(Integer v) { this.hasTranslatedText = v; } public void setHasTranslatedText(Integer v) { this.hasTranslatedText = v; }
public Long getParentDocumentId() { return parentDocumentId; }
public void setParentDocumentId(Long v) { this.parentDocumentId = v; }
public Integer getIsReference() { return isReference; }
public void setIsReference(Integer v) { this.isReference = v; }
/** true, wenn dieses Dokument eine heruntergeladene Referenz ist. */
public boolean isReference() { return Integer.valueOf(1).equals(isReference); }
public String getSourceUrl() { return sourceUrl; }
public void setSourceUrl(String v) { this.sourceUrl = v; }
public Integer getRowVersion() { return rowVersion; } public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; } public void setRowVersion(Integer v) { this.rowVersion = v; }

View File

@@ -17,6 +17,9 @@ public class OrdsProject {
private Integer progress; // 0100 private Integer progress; // 0100
private LocalDateTime completedAt; private LocalDateTime completedAt;
private String notificationEmail; private String notificationEmail;
private String notifyOk; // 'Y' | 'N'
private String notifyHinweis; // 'Y' | 'N'
private String notifyAbweichend; // 'Y' | 'N'
private Integer rowVersion; private Integer rowVersion;
private LocalDateTime created; private LocalDateTime created;
private LocalDateTime updated; private LocalDateTime updated;
@@ -50,6 +53,15 @@ public class OrdsProject {
public String getNotificationEmail() { return notificationEmail; } public String getNotificationEmail() { return notificationEmail; }
public void setNotificationEmail(String v) { this.notificationEmail = v; } public void setNotificationEmail(String v) { this.notificationEmail = v; }
public String getNotifyOk() { return notifyOk; }
public void setNotifyOk(String v) { this.notifyOk = v; }
public String getNotifyHinweis() { return notifyHinweis; }
public void setNotifyHinweis(String v) { this.notifyHinweis = v; }
public String getNotifyAbweichend() { return notifyAbweichend; }
public void setNotifyAbweichend(String v) { this.notifyAbweichend = v; }
public Integer getRowVersion() { return rowVersion; } public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; } public void setRowVersion(Integer v) { this.rowVersion = v; }

View File

@@ -0,0 +1,38 @@
package de.frigosped.dc.model;
/**
* Request-Body für POST /api/dc/documents/:parent_id/references
* {"source_url":"https://...","filename":"AGB.pdf",
* "mime_type":"application/pdf","file_base64":"JVBERi0x..."}
*
* Legt eine heruntergeladene externe Referenz als neue dc_project_documents-Zeile an.
*/
public class ReferenceCreateRequest {
private String sourceUrl;
private String filename;
private String mimeType;
private String fileBase64;
public ReferenceCreateRequest() {}
public ReferenceCreateRequest(String sourceUrl, String filename,
String mimeType, String fileBase64) {
this.sourceUrl = sourceUrl;
this.filename = filename;
this.mimeType = mimeType;
this.fileBase64 = fileBase64;
}
public String getSourceUrl() { return sourceUrl; }
public void setSourceUrl(String v) { this.sourceUrl = v; }
public String getFilename() { return filename; }
public void setFilename(String v) { this.filename = v; }
public String getMimeType() { return mimeType; }
public void setMimeType(String v) { this.mimeType = v; }
public String getFileBase64() { return fileBase64; }
public void setFileBase64(String v) { this.fileBase64 = v; }
}

View File

@@ -14,6 +14,7 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -48,6 +49,22 @@ public class CheckOrchestrationService {
@Inject @Inject
EvaluationService evaluationService; EvaluationService evaluationService;
@Inject
MarkdownReportBuilder markdownReportBuilder;
@Inject
MarkdownExportService markdownExportService;
@Inject
ReferenceDownloadService referenceDownloadService;
@Inject
com.fasterxml.jackson.databind.ObjectMapper objectMapper;
@org.eclipse.microprofile.config.inject.ConfigProperty(
name = "dc.references.max-per-document", defaultValue = "5")
int maxReferencesPerDocument;
// ========================================================================= // =========================================================================
// Haupt-Einstiegspunkt // Haupt-Einstiegspunkt
// ========================================================================= // =========================================================================
@@ -82,7 +99,7 @@ public class CheckOrchestrationService {
if (documents.isEmpty()) { if (documents.isEmpty()) {
LOG.warnf("Projekt %d hat keine Dokumente markiere als abgeschlossen", projectId); LOG.warnf("Projekt %d hat keine Dokumente markiere als abgeschlossen", projectId);
ordsClient.completeProject(projectId); ordsClient.completeProject(projectId, new CompleteProjectRequest("", java.util.Collections.emptyList()));
return; return;
} }
@@ -115,28 +132,39 @@ public class CheckOrchestrationService {
} }
int[] stepHolder = {0}; int[] stepHolder = {0};
// 6. Alte Ergebnisse löschen (ermöglicht Re-Processing) // 6. Alte Ergebnisse + heruntergeladene Referenzen löschen (ermöglicht Re-Processing)
Response delResp = ordsClient.deleteResults(projectId); Response delResp = ordsClient.deleteResults(projectId);
LOG.debugf("Alte Ergebnisse gelöscht: HTTP %d", delResp.getStatus()); LOG.debugf("Alte Ergebnisse gelöscht: HTTP %d", delResp.getStatus());
try {
Response delRefResp = ordsClient.deleteReferences(projectId);
LOG.debugf("Alte Referenzen gelöscht: HTTP %d", delRefResp.getStatus());
} catch (Exception e) {
LOG.debugf("Referenzen löschen fehlgeschlagen: %s", e.getMessage());
}
// Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS) // Texte und Ergebnisse sammeln (in-memory für Markdown-Report)
Map<Long, String> originalTexts = new HashMap<>(); Map<Long, String> originalTexts = new HashMap<>();
Map<Long, List<ResultRequest>> resultsByDoc = new HashMap<>();
// Original-Texte heruntergeladener Referenzen je Hauptdokument-ID
Map<Long, List<String>> referenceTextsByDoc = new HashMap<>();
// ─── PHASE A: OCR + Übersetzung (wird übersprungen wenn Text bereits vorhanden) ── // ─── PHASE A: OCR + Übersetzung (wird übersprungen wenn Text bereits vorhanden) ──
for (OrdsDocument doc : documents) { for (OrdsDocument doc : documents) {
// Vor dem Schritt: Status auf "OCR für dieses Dokument" setzen if (doc.isReference()) continue; // Referenzen werden während Phase A erzeugt
pushProgress(projectId, stepHolder[0], totalSteps, "OCR", pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
doc.getId(), null, startedAt); doc.getId(), null, startedAt);
pushDocumentProgress(doc.getId(), 0, "IN_PROGRESS");
String originalText = ""; String originalText = "";
if (Integer.valueOf(1).equals(doc.getHasOriginalText())) { if (Integer.valueOf(1).equals(doc.getHasOriginalText())) {
// Resume: Text aus vorherigem Lauf wiederverwenden
LOG.infof("Dokument %d: Text bereits vorhanden überspringe OCR/Übersetzung", LOG.infof("Dokument %d: Text bereits vorhanden überspringe OCR/Übersetzung",
doc.getId()); doc.getId());
try { try {
OrdsDocumentTexts texts = ordsClient.getTexts(doc.getId()); OrdsDocumentTexts texts = ordsClient.getTexts(doc.getId());
originalText = texts.getOriginalText() != null ? texts.getOriginalText() : ""; originalText = texts.getOriginalText() != null ? texts.getOriginalText() : "";
pushDocumentProgress(doc.getId(), 50, null);
} catch (Exception e) { } catch (Exception e) {
LOG.warnf("Text-Laden für Dokument %d fehlgeschlagen: %s", doc.getId(), e.getMessage()); LOG.warnf("Text-Laden für Dokument %d fehlgeschlagen: %s", doc.getId(), e.getMessage());
} }
@@ -148,6 +176,7 @@ public class CheckOrchestrationService {
if (fileResp.getStatus() != 200) { if (fileResp.getStatus() != 200) {
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)", LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
doc.getId(), fileResp.getStatus()); doc.getId(), fileResp.getStatus());
pushDocumentProgress(doc.getId(), null, "ERROR");
} else { } else {
byte[] fileBytes = fileResp.readEntity(byte[].class); byte[] fileBytes = fileResp.readEntity(byte[].class);
@@ -155,22 +184,34 @@ public class CheckOrchestrationService {
fileBytes, doc.getMimeType(), doc.getFilename(), projectId); fileBytes, doc.getMimeType(), doc.getFilename(), projectId);
LOG.debugf("Dokument %d: %d Zeichen extrahiert", LOG.debugf("Dokument %d: %d Zeichen extrahiert",
(long) doc.getId(), (long) originalText.length()); (long) doc.getId(), (long) originalText.length());
pushDocumentProgress(doc.getId(), 25, null);
translatedText = evaluationService.translate(originalText, projectId); translatedText = evaluationService.translate(originalText, projectId);
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)", LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
(long) doc.getId(), (long) translatedText.length()); (long) doc.getId(), (long) translatedText.length());
pushDocumentProgress(doc.getId(), 50, null);
ordsClient.updateTexts(doc.getId(), ordsClient.updateTexts(doc.getId(),
new TextsRequest(originalText, translatedText)); new TextsRequest(originalText, translatedText));
} }
} catch (Exception e) { } catch (Exception e) {
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId()); LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
pushDocumentProgress(doc.getId(), null, "ERROR");
} }
} }
originalTexts.put(doc.getId(), originalText); originalTexts.put(doc.getId(), originalText);
// Nach dem Schritt: Fortschritt hochzählen + ETA aktualisieren // Externe Referenzen erkennen, herunterladen und als eigene Zeilen speichern
if (!originalText.isBlank() && referenceDownloadService.isEnabled()) {
try {
processReferences(doc, originalText, projectId, referenceTextsByDoc);
} catch (Exception e) {
LOG.warnf("Referenz-Verarbeitung für Dokument %d fehlgeschlagen: %s",
doc.getId(), e.getMessage());
}
}
stepHolder[0]++; stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps, "OCR", pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
doc.getId(), null, startedAt); doc.getId(), null, startedAt);
@@ -178,7 +219,12 @@ public class CheckOrchestrationService {
// ─── PHASE B: Fragen auswerten ──────────────────────────────── // ─── PHASE B: Fragen auswerten ────────────────────────────────
for (OrdsDocument doc : documents) { for (OrdsDocument doc : documents) {
String originalText = originalTexts.getOrDefault(doc.getId(), ""); if (doc.isReference()) continue; // Referenzen werden nur „in Summe" mitbewertet
// Auswertung über Haupt- + Referenztext (Option D: „Summe")
String originalText = buildCombinedText(
originalTexts.getOrDefault(doc.getId(), ""),
referenceTextsByDoc.get(doc.getId()));
Long catId = doc.getCatalogId(); Long catId = doc.getCatalogId();
if (catId == null || !questionsByCatalog.containsKey(catId)) { if (catId == null || !questionsByCatalog.containsKey(catId)) {
@@ -197,6 +243,7 @@ public class CheckOrchestrationService {
} }
List<List<OrdsQuestion>> batches = partition(questions, 4); List<List<OrdsQuestion>> batches = partition(questions, 4);
int batchesDone = 0;
for (List<OrdsQuestion> batch : batches) { for (List<OrdsQuestion> batch : batches) {
OrdsQuestion first = batch.get(0); OrdsQuestion first = batch.get(0);
@@ -217,17 +264,64 @@ public class CheckOrchestrationService {
for (OrdsQuestion question : batch) { for (OrdsQuestion question : batch) {
EvaluationResult result = batchResults.get(question.getQuestionId()); EvaluationResult result = batchResults.get(question.getQuestionId());
if (result == null) continue; if (result == null) continue;
saveResult(projectId, doc.getId(), question, result); ResultRequest rr = saveResult(projectId, doc.getId(), question, result);
if (rr != null) {
resultsByDoc.computeIfAbsent(doc.getId(), k -> new ArrayList<>()).add(rr);
}
} }
stepHolder[0] += batch.size(); stepHolder[0] += batch.size();
batchesDone += batch.size();
int docProgress = 50 + (int) Math.min(batchesDone * 50L / questions.size(), 49);
pushDocumentProgress(doc.getId(), docProgress, null);
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS", pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
doc.getId(), batch.get(batch.size() - 1).getQuestionId(), startedAt); doc.getId(), batch.get(batch.size() - 1).getQuestionId(), startedAt);
} }
pushDocumentProgress(doc.getId(), 100, "COMPLETED");
} }
// 7. Abschließen // 7. Kombinierten Markdown-Bericht für die DB generieren
ordsClient.completeProject(projectId); String reportMarkdown = "";
try {
reportMarkdown = markdownReportBuilder.build(
project, documents, questionsByCatalog, resultsByDoc);
LOG.infof("Projekt %d: Markdown-Bericht generiert (%d Zeichen)",
projectId, reportMarkdown.length());
} catch (Exception e) {
LOG.warnf(e, "Markdown-Generierung fehlgeschlagen sende leeren Body");
}
// 8. Pro Dokument ein eigenes PDF generieren
Map<Long, OrdsQuestion> questionById = new HashMap<>();
for (List<OrdsQuestion> qs : questionsByCatalog.values()) {
for (OrdsQuestion q : qs) questionById.put(q.getQuestionId(), q);
}
List<DocumentPdfEntry> documentPdfs = new ArrayList<>();
for (OrdsDocument doc : documents) {
List<ResultRequest> docResults =
resultsByDoc.getOrDefault(doc.getId(), java.util.Collections.emptyList());
if (docResults.isEmpty()) continue;
try {
String docMd = markdownReportBuilder.buildForDocument(
project, doc, questionById, docResults);
byte[] pdfBytes = markdownExportService.toPdf(docMd);
String baseName = doc.getFilename().replaceAll("\\.[^.]+$", "");
String filename = "Pruefbericht_"
+ baseName.replaceAll("[^A-Za-z0-9_äöüÄÖÜß.-]", "_")
+ ".pdf";
documentPdfs.add(new DocumentPdfEntry(filename,
Base64.getEncoder().encodeToString(pdfBytes)));
LOG.infof("Projekt %d: Dok %d PDF generiert (%d Bytes)",
projectId, doc.getId(), pdfBytes.length);
} catch (Exception e) {
LOG.warnf(e, "PDF-Generierung für Dok %d fehlgeschlagen", doc.getId());
}
}
ordsClient.completeProject(projectId,
new CompleteProjectRequest(reportMarkdown, documentPdfs));
LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId); LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId);
} catch (Exception e) { } catch (Exception e) {
@@ -257,6 +351,98 @@ public class CheckOrchestrationService {
} }
} }
/** Setzt progress und/oder status eines Dokuments. null-Werte werden ignoriert. */
private void pushDocumentProgress(long docId, Integer progress, String status) {
try {
jakarta.ws.rs.core.Response resp =
ordsClient.updateDocumentProgress(docId, new DocumentProgressRequest(progress, status));
if (resp.getStatus() < 200 || resp.getStatus() >= 300) {
LOG.warnf("Dokument-Fortschritt: HTTP %d (Dok %d, progress=%s, status=%s)",
resp.getStatus(), docId, progress, status);
}
} catch (Exception e) {
LOG.warnf("Dokument-Fortschritt konnte nicht gesetzt werden (Dok %d): %s", docId, e.getMessage());
}
}
/**
* Erkennt externe Referenzen im Dokumenttext, lädt sie sicher herunter,
* legt sie als eigene dc_project_documents-Zeilen an (is_reference=1) und
* sammelt ihren Original-Text für die spätere „Summen"-Auswertung.
*/
private void processReferences(OrdsDocument doc, String originalText, long projectId,
Map<Long, List<String>> referenceTextsByDoc) {
List<String> urls = evaluationService.extractReferenceUrls(originalText, projectId);
if (urls.isEmpty()) return;
LOG.infof("Dokument %d: %d Referenz-URL(s) vom Modell erkannt", doc.getId(), urls.size());
int processed = 0;
for (String url : urls) {
if (processed >= maxReferencesPerDocument) {
LOG.infof("Dokument %d: Referenz-Limit (%d) erreicht Rest ignoriert",
doc.getId(), maxReferencesPerDocument);
break;
}
var fetched = referenceDownloadService.fetch(url, projectId);
if (fetched.isEmpty()) continue;
ReferenceDownloadService.DownloadedReference ref = fetched.get();
try {
// 1. Referenz als neue Dokument-Zeile (mit BLOB) anlegen
String base64 = Base64.getEncoder().encodeToString(ref.bytes());
Response insResp = ordsClient.insertReference(doc.getId(),
new ReferenceCreateRequest(ref.sourceUrl(), ref.filename(),
ref.mimeType(), base64));
String body = insResp.readEntity(String.class);
if (insResp.getStatus() != 201) {
LOG.warnf("Referenz-Insert fehlgeschlagen (HTTP %d): %s", insResp.getStatus(), body);
continue;
}
long refId = extractId(body);
// 2. Texte der Referenz speichern (Original + Übersetzung) für separate Ansicht
String refTranslated = evaluationService.translate(ref.text(), projectId);
ordsClient.updateTexts(refId, new TextsRequest(ref.text(), refTranslated));
pushDocumentProgress(refId, 100, "COMPLETED");
// 3. Original-Text für die „Summen"-Auswertung des Hauptdokuments merken
referenceTextsByDoc.computeIfAbsent(doc.getId(), k -> new ArrayList<>())
.add("<!-- Referenz: " + ref.sourceUrl() + " -->\n" + ref.text());
processed++;
LOG.infof("Dokument %d: Referenz gespeichert (id=%d, %s)",
doc.getId(), refId, ref.sourceUrl());
} catch (Exception e) {
LOG.warnf("Referenz '%s' konnte nicht gespeichert werden: %s", url, e.getMessage());
}
}
}
/** Liest das Feld "id" aus einer ORDS-Insert-Antwort. */
private long extractId(String responseBody) throws Exception {
Map<?, ?> map = objectMapper.readValue(responseBody, Map.class);
Object id = map.get("id");
if (id instanceof Number n) return n.longValue();
throw new IllegalStateException("Kein 'id'-Feld in ORDS-Antwort: " + responseBody);
}
/**
* Fügt Haupttext und Referenztexte zu einem Bewertungstext zusammen (Option D).
* Die Referenzen werden klar abgegrenzt angehängt.
*/
private String buildCombinedText(String mainText, List<String> referenceTexts) {
if (referenceTexts == null || referenceTexts.isEmpty()) {
return mainText != null ? mainText : "";
}
StringBuilder sb = new StringBuilder(mainText != null ? mainText : "");
for (String refText : referenceTexts) {
sb.append("\n\n--- Referenzdokument ---\n\n").append(refText);
}
return sb.toString();
}
/** Teilt eine Liste in Untergruppen der gewünschten Größe auf. */ /** Teilt eine Liste in Untergruppen der gewünschten Größe auf. */
private static <T> List<List<T>> partition(List<T> list, int size) { private static <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> parts = new ArrayList<>(); List<List<T>> parts = new ArrayList<>();
@@ -282,14 +468,20 @@ public class CheckOrchestrationService {
return map; return map;
} }
private void saveResult(long projectId, long docId, private ResultRequest saveResult(long projectId, long docId,
OrdsQuestion question, EvaluationResult result) { OrdsQuestion question, EvaluationResult result) {
try { try {
int deviation = 0, warning = 0; int deviation = 0, warning = 0;
if (result.getScore() != null && question.getThreshold() != null if (result.getScore() != null && question.getThreshold() != null
&& result.getScore() < question.getThreshold()) { && result.getScore() < question.getThreshold()) {
if ("ABWEICHUNG".equalsIgnoreCase(question.getResultHandling())) deviation = 1; if ("UNKLAR".equalsIgnoreCase(result.getResultType())) {
else if ("HINWEIS".equalsIgnoreCase(question.getResultHandling())) warning = 1; // Teilweise Erfüllung (falscher Paragraph, gleiches Ziel, o.ä.) → immer Hinweis
warning = 1;
} else if ("ABWEICHUNG".equalsIgnoreCase(question.getResultHandling())) {
deviation = 1;
} else if ("HINWEIS".equalsIgnoreCase(question.getResultHandling())) {
warning = 1;
}
} }
ResultRequest rr = new ResultRequest( ResultRequest rr = new ResultRequest(
question.getQuestionId(), docId, question.getQuestionId(), docId,
@@ -301,9 +493,11 @@ public class CheckOrchestrationService {
LOG.warnf("Ergebnis speichern: HTTP %d (Frage %d, Dok %d)", LOG.warnf("Ergebnis speichern: HTTP %d (Frage %d, Dok %d)",
saveResp.getStatus(), question.getQuestionId(), docId); saveResp.getStatus(), question.getQuestionId(), docId);
} }
return rr;
} catch (Exception e) { } catch (Exception e) {
LOG.errorf(e, "Ergebnis-Speichern fehlgeschlagen: Frage %d / Dokument %d", LOG.errorf(e, "Ergebnis-Speichern fehlgeschlagen: Frage %d / Dokument %d",
question.getQuestionId(), docId); question.getQuestionId(), docId);
return null;
} }
} }

View File

@@ -1,5 +1,8 @@
package de.frigosped.dc.service; package de.frigosped.dc.service;
import de.frigosped.dc.client.MistralOcrClient;
import de.frigosped.dc.model.MistralOcrRequest;
import de.frigosped.dc.model.MistralOcrResponse;
import dev.langchain4j.data.message.ImageContent; import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent; import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.message.UserMessage;
@@ -11,7 +14,10 @@ import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.xwpf.usermodel.*; import org.apache.poi.xwpf.usermodel.*;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.odftoolkit.odfdom.doc.OdfTextDocument; import org.odftoolkit.odfdom.doc.OdfTextDocument;
import org.w3c.dom.Node; import org.w3c.dom.Node;
@@ -42,13 +48,26 @@ public class DocumentProcessingService {
/** 200 DPI: gutes Gleichgewicht zwischen Qualität und Dateigröße für OCR */ /** 200 DPI: gutes Gleichgewicht zwischen Qualität und Dateigröße für OCR */
private static final float OCR_DPI = 200f; private static final float OCR_DPI = 200f;
/** Mindest-Zeichen pro Seite darunter gilt das PDF als gescannt */
private static final int MIN_CHARS_PER_PAGE = 50;
@Inject @Inject
@Named("ocr") @Named("ocr")
ChatLanguageModel ocrModel; ChatLanguageModel ocrModel;
@Inject
@RestClient
MistralOcrClient mistralOcrClient;
@Inject @Inject
AiCostTrackerService costTracker; AiCostTrackerService costTracker;
@ConfigProperty(name = "dc.ai.provider", defaultValue = "ollama")
String aiProvider;
@ConfigProperty(name = "dc.ai.mistral.api-key", defaultValue = "")
String mistralApiKey;
// ========================================================================= // =========================================================================
// Öffentliche API // Öffentliche API
// ========================================================================= // =========================================================================
@@ -96,28 +115,32 @@ public class DocumentProcessingService {
// ========================================================================= // =========================================================================
private String extractFromPdf(byte[] fileBytes, Long projectId) throws Exception { private String extractFromPdf(byte[] fileBytes, Long projectId) throws Exception {
LOG.debug("Starte PDF-OCR...");
StringBuilder result = new StringBuilder();
try (PDDocument pdf = Loader.loadPDF(fileBytes)) { try (PDDocument pdf = Loader.loadPDF(fileBytes)) {
PDFRenderer renderer = new PDFRenderer(pdf);
int pageCount = pdf.getNumberOfPages(); int pageCount = pdf.getNumberOfPages();
LOG.debugf("PDF hat %d Seite(n)", pageCount); LOG.debugf("PDF hat %d Seite(n)", pageCount);
String extracted = new PDFTextStripper().getText(pdf).trim();
if (extracted.length() >= pageCount * MIN_CHARS_PER_PAGE) {
LOG.debugf("PDF-Textextraktion: %d Zeichen", extracted.length());
return extracted;
}
LOG.debugf("PDF hat wenig eingebetteten Text (%d Zeichen) verwende Vision-OCR", extracted.length());
if ("mistral".equalsIgnoreCase(aiProvider)) {
return mistralOcr(fileBytes);
}
PDFRenderer renderer = new PDFRenderer(pdf);
StringBuilder result = new StringBuilder();
for (int i = 0; i < pageCount; i++) { for (int i = 0; i < pageCount; i++) {
LOG.debugf("OCR Seite %d/%d", i + 1, pageCount); LOG.debugf("OCR Seite %d/%d", i + 1, pageCount);
BufferedImage image = renderer.renderImageWithDPI(i, OCR_DPI, ImageType.RGB); BufferedImage image = renderer.renderImageWithDPI(i, OCR_DPI, ImageType.RGB);
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos); ImageIO.write(image, "PNG", baos);
String base64Image = Base64.getEncoder().encodeToString(baos.toByteArray()); String base64Image = Base64.getEncoder().encodeToString(baos.toByteArray());
result.append(ocrPage(base64Image, i + 1, pageCount, projectId)).append("\n\n");
String pageText = ocrPage(base64Image, i + 1, pageCount, projectId);
result.append(pageText).append("\n\n");
} }
return result.toString().trim();
} }
return result.toString().trim();
} }
private String ocrPage(String base64Image, int pageNum, int totalPages, Long projectId) { private String ocrPage(String base64Image, int pageNum, int totalPages, Long projectId) {
@@ -143,6 +166,31 @@ public class DocumentProcessingService {
return response.content().text(); return response.content().text();
} }
private String mistralOcr(byte[] fileBytes) {
LOG.debug("Mistral OCR API sende PDF direkt...");
String base64 = Base64.getEncoder().encodeToString(fileBytes);
String dataUrl = "data:application/pdf;base64," + base64;
MistralOcrRequest request = new MistralOcrRequest(
"mistral-ocr-latest",
new MistralOcrRequest.Document("document_url", dataUrl)
);
MistralOcrResponse response = mistralOcrClient.ocr("Bearer " + mistralApiKey, request);
StringBuilder result = new StringBuilder();
for (MistralOcrResponse.Page page : response.getPages().stream()
.sorted(java.util.Comparator.comparingInt(MistralOcrResponse.Page::getIndex))
.toList()) {
if (page.getMarkdown() != null && !page.getMarkdown().isBlank()) {
result.append(page.getMarkdown()).append("\n\n");
}
}
LOG.debugf("Mistral OCR abgeschlossen: %d Seite(n), %d Zeichen",
response.getPages().size(), result.length());
return result.toString().trim();
}
// ========================================================================= // =========================================================================
// DOCX → Markdown (Apache POI) // DOCX → Markdown (Apache POI)
// ========================================================================= // =========================================================================

View File

@@ -19,6 +19,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
/** /**
* KI-gestützte Auswertung: * KI-gestützte Auswertung:
@@ -78,7 +79,79 @@ public class EvaluationService {
Response<AiMessage> response = mainModel.generate(messages); Response<AiMessage> response = mainModel.generate(messages);
costTracker.track("main", "TRANSLATE", projectId, null, response.tokenUsage()); costTracker.track("main", "TRANSLATE", projectId, null, response.tokenUsage());
return response.content().text().trim(); return stripCodeFence(response.content().text().trim());
}
/**
* Entfernt eine umschließende ```-Markierung (z. B. ```markdown), die das
* Modell der Übersetzung manchmal trotz Anweisung hinzufügt. So wird der
* Markdown-Text einheitlich ohne Code-Fence gespeichert.
*/
private String stripCodeFence(String text) {
if (text == null) return "";
String t = text.trim();
if (t.startsWith("```")) {
int firstNewline = t.indexOf('\n');
if (firstNewline > 0) {
t = t.substring(firstNewline + 1); // erste Zeile (```markdown) entfernen
}
if (t.endsWith("```")) {
t = t.substring(0, t.length() - 3); // schließendes ``` entfernen
}
}
return t.trim();
}
// =========================================================================
// Referenz-Erkennung
// =========================================================================
/**
* Lässt das Hauptmodell die relevanten externen Dokument-Referenz-URLs direkt
* aus dem Dokumenttext ausgeben (kein Regex). Login-, Video-, Impressum- und
* Portalseiten werden anhand des Kontexts herausgefiltert.
*
* @return Liste der vom Modell gelieferten URLs (kann leer sein)
*/
public List<String> extractReferenceUrls(String documentText, long projectId) {
if (documentText == null || documentText.isBlank()) return List.of();
List<ChatMessage> messages = List.of(
SystemMessage.from(
"Du analysierst Transport- und Speditionsdokumente und findest Verweise auf\n" +
"EXTERNE Dokumente, die per Link erreichbar sind (z. B. AGB, Allgemeine\n" +
"Geschäftsbedingungen, Verhaltenskodex/Code of Conduct, Vertragsbedingungen,\n" +
"Sicherheits- oder Compliance-Richtlinien).\n\n" +
"Gib NUR Links zurück, die tatsächlich im Text stehen und auf ein solches\n" +
"Dokument verweisen. Schließe ausdrücklich AUS: Login-/Portalseiten,\n" +
"Video-Links (z. B. YouTube), Impressum, Datenschutz-Übersichtsseiten,\n" +
"Hinweisgeber-/Whistleblower-Portale, Social-Media- und reine Startseiten.\n\n" +
"Verändere die URLs nicht gib sie exakt so zurück, wie sie im Text stehen.\n\n" +
"Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Array von Strings (URLs).\n" +
"Wenn es keine passenden Links gibt, antworte mit []."
),
UserMessage.from("## Dokument\n\n" + documentText + "\n\n---\n\nAntworte mit dem JSON-Array der relevanten URLs:")
);
try {
Response<AiMessage> response = mainModel.generate(messages);
costTracker.track("main", "REF_EXTRACT", projectId, null, response.tokenUsage());
String json = extractJsonArray(response.content().text());
List<String> urls = objectMapper.readValue(
json,
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class)
);
// Duplikate entfernen, leere Einträge filtern, Reihenfolge erhalten
return urls.stream()
.filter(u -> u != null && !u.isBlank())
.map(String::trim)
.distinct()
.collect(Collectors.toList());
} catch (Exception e) {
LOG.warnf("Referenz-URL-Extraktion fehlgeschlagen: %s", e.getMessage());
return List.of();
}
} }
// ========================================================================= // =========================================================================
@@ -163,6 +236,11 @@ public class EvaluationService {
Du bist ein Experte für Transportrecht, AGB-Recht und Vertragsanalyse. Du bist ein Experte für Transportrecht, AGB-Recht und Vertragsanalyse.
Du prüfst Transport- und Speditionsdokumente gegen einen Fragenkatalog. Du prüfst Transport- und Speditionsdokumente gegen einen Fragenkatalog.
Wichtige Suchregel: Suche im Dokument ZUERST nach expliziten, wörtlichen Formulierungen,
die die Anforderung direkt bestätigen oder verneinen. Zitiere immer die stärkste,
direkteste Textstelle. Nutze indirekte oder implizite Belege NUR dann, wenn keine
direkte Formulierung im Dokument existiert.
Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Array kein Text davor oder danach. Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Array kein Text davor oder danach.
Das Array enthält für JEDE Frage genau ein Objekt: Das Array enthält für JEDE Frage genau ein Objekt:
@@ -275,6 +353,11 @@ public class EvaluationService {
Du bist ein Experte für Transportrecht, AGB-Recht und Vertragsanalyse. Du bist ein Experte für Transportrecht, AGB-Recht und Vertragsanalyse.
Du prüfst Transport- und Speditionsdokumente gegen einen Fragenkatalog. Du prüfst Transport- und Speditionsdokumente gegen einen Fragenkatalog.
Wichtige Suchregel: Suche im Dokument ZUERST nach expliziten, wörtlichen Formulierungen,
die die Anforderung direkt bestätigen oder verneinen. Zitiere immer die stärkste,
direkteste Textstelle. Nutze indirekte oder implizite Belege NUR dann, wenn keine
direkte Formulierung im Dokument existiert.
Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Objekt kein Text davor oder danach. Antworte AUSSCHLIESSLICH mit einem gültigen JSON-Objekt kein Text davor oder danach.
Das JSON-Objekt muss folgende Felder enthalten: Das JSON-Objekt muss folgende Felder enthalten:

View File

@@ -20,6 +20,12 @@ import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/** /**
* Konvertiert Markdown-Text zu DOCX (Apache POI) oder PDF (PDFBox). * Konvertiert Markdown-Text zu DOCX (Apache POI) oder PDF (PDFBox).
@@ -66,41 +72,90 @@ public class MarkdownExportService {
|| cp == 0x1F4C4; // 📄 || cp == 0x1F4C4; // 📄
} }
/** Findet das schließende einzelne * ab Position from; überspringt ** -Paare. */
private static int findItalicClose(String text, int from) {
int end = text.indexOf('*', from);
while (end >= 0) {
if (end + 1 >= text.length() || text.charAt(end + 1) != '*') return end;
end = text.indexOf('*', end + 2);
}
return -1;
}
/** /**
* Zerlegt eine Zeile in typisierte Inline-Segmente. * Zerlegt text in typisierte Inline-Segmente mit Zeilenübergreifender Format-Fortsetzung.
* *
* Erkennungsreihenfolge pro Position: * openFormatRef[0] gibt das ggf. bereits offene Format vom vorherigen Aufruf an
* 1. **text** → BOLD * (null = keines, BOLD = innerhalb **, ITALIC = innerhalb *).
* 2. *text* → ITALIC (nur wenn nicht Teil von **) * Nach dem Aufruf enthält openFormatRef[0] das weiterhin offene Format.
* 3. isReportEmoji() → EMOJI (inkl. nachfolgende Variation-Selektoren U+FE0F / ZWJ U+200D)
* 4. alles andere → PLAIN (gesammelt bis zum nächsten Marker oder Emoji)
* *
* Beispiel: "✅ **Frage** ist *kursiv*" * Wird ** oder * geöffnet aber auf dieser Zeile nicht geschlossen, erstreckt sich
* → [EMOJI "✅"], [PLAIN " "], [BOLD "Frage"], [PLAIN " ist "], [ITALIC "kursiv"] * die Formatierung bis ans Zeilenende und openFormatRef[0] wird entsprechend gesetzt,
* damit der nächste Aufruf die Formatierung nahtlos fortsetzt.
*/ */
static List<Seg> parseSegments(String text) { static List<Seg> parseSegments(String text, SegType[] openFormatRef) {
List<Seg> out = new ArrayList<>(); List<Seg> out = new ArrayList<>();
if (text == null || text.isEmpty()) return out; if (text == null || text.isEmpty()) return out;
SegType open = openFormatRef[0];
int pos = 0; int pos = 0;
// Format-Fortsetzung von der Vorgängerzeile aufnehmen
if (open == SegType.BOLD) {
int end = text.indexOf("**");
if (end >= 0) {
if (end > 0) out.add(new Seg(SegType.BOLD, text.substring(0, end)));
pos = end + 2;
open = null;
} else {
out.add(new Seg(SegType.BOLD, text));
openFormatRef[0] = SegType.BOLD;
return out;
}
} else if (open == SegType.ITALIC) {
int end = findItalicClose(text, 0);
if (end >= 0) {
if (end > 0) out.add(new Seg(SegType.ITALIC, text.substring(0, end)));
pos = end + 1;
open = null;
} else {
out.add(new Seg(SegType.ITALIC, text));
openFormatRef[0] = SegType.ITALIC;
return out;
}
}
while (pos < text.length()) { while (pos < text.length()) {
// **bold** // **bold**
if (text.startsWith("**", pos)) { if (text.startsWith("**", pos)) {
int end = text.indexOf("**", pos + 2); int end = text.indexOf("**", pos + 2);
if (end > pos + 1) { if (end >= 0) {
out.add(new Seg(SegType.BOLD, text.substring(pos + 2, end))); out.add(new Seg(SegType.BOLD, text.substring(pos + 2, end)));
pos = end + 2; pos = end + 2;
continue; } else {
// kein schließendes ** auf dieser Zeile → bis Zeilenende + Folgezeile
String boldText = text.substring(pos + 2);
if (!boldText.isEmpty()) out.add(new Seg(SegType.BOLD, boldText));
open = SegType.BOLD;
pos = text.length();
} }
continue;
} }
// *italic* (not part of **) // *italic* (not part of **)
if (text.charAt(pos) == '*' if (text.charAt(pos) == '*'
&& (pos + 1 >= text.length() || text.charAt(pos + 1) != '*')) { && (pos + 1 >= text.length() || text.charAt(pos + 1) != '*')) {
int end = text.indexOf('*', pos + 1); int end = findItalicClose(text, pos + 1);
if (end > pos && (end + 1 >= text.length() || text.charAt(end + 1) != '*')) { if (end >= 0) {
out.add(new Seg(SegType.ITALIC, text.substring(pos + 1, end))); out.add(new Seg(SegType.ITALIC, text.substring(pos + 1, end)));
pos = end + 1; pos = end + 1;
continue; } else {
// kein schließendes * auf dieser Zeile → bis Zeilenende + Folgezeile
String italicText = text.substring(pos + 1);
if (!italicText.isEmpty()) out.add(new Seg(SegType.ITALIC, italicText));
open = SegType.ITALIC;
pos = text.length();
} }
continue;
} }
// Bekanntes Report-Emoji // Bekanntes Report-Emoji
int cp = text.codePointAt(pos); int cp = text.codePointAt(pos);
@@ -124,9 +179,16 @@ public class MarkdownExportService {
} }
if (pos > start) out.add(new Seg(SegType.PLAIN, text.substring(start, pos))); if (pos > start) out.add(new Seg(SegType.PLAIN, text.substring(start, pos)));
} }
openFormatRef[0] = open;
return out; return out;
} }
/** Überladung ohne Zustandsverfolgung (für Tabellenzellen und isolierte Aufrufe). */
static List<Seg> parseSegments(String text) {
return parseSegments(text, new SegType[]{null});
}
// ========================================================================= // =========================================================================
// DOCX-Export via Apache POI // DOCX-Export via Apache POI
// ========================================================================= // =========================================================================
@@ -154,22 +216,30 @@ public class MarkdownExportService {
try (XWPFDocument doc = new XWPFDocument()) { try (XWPFDocument doc = new XWPFDocument()) {
String[] lines = markdown.replace("\r", "").split("\n", -1); String[] lines = markdown.replace("\r", "").split("\n", -1);
int i = 0; int i = 0;
SegType[] inlineState = {null}; // zeilenübergreifendes Bold/Italic-State
while (i < lines.length) { while (i < lines.length) {
String line = lines[i]; String line = lines[i];
if (line.startsWith("#### ")) { if (line.startsWith("#### ")) {
inlineState[0] = null;
addHeading(doc, line.substring(5).trim(), 4); addHeading(doc, line.substring(5).trim(), 4);
} else if (line.startsWith("### ")) { } else if (line.startsWith("### ")) {
inlineState[0] = null;
addHeading(doc, line.substring(4).trim(), 3); addHeading(doc, line.substring(4).trim(), 3);
} else if (line.startsWith("## ")) { } else if (line.startsWith("## ")) {
inlineState[0] = null;
addHeading(doc, line.substring(3).trim(), 2); addHeading(doc, line.substring(3).trim(), 2);
} else if (line.startsWith("# ")) { } else if (line.startsWith("# ")) {
inlineState[0] = null;
addHeading(doc, line.substring(2).trim(), 1); addHeading(doc, line.substring(2).trim(), 1);
} else if (line.equals("---")) { } else if (line.equals("---")) {
inlineState[0] = null;
addHorizontalRule(doc); addHorizontalRule(doc);
} else if (line.startsWith("> ")) { } else if (line.startsWith("> ")) {
inlineState[0] = null;
addBlockquote(doc, line.substring(2).trim()); addBlockquote(doc, line.substring(2).trim());
} else if (line.startsWith("|")) { } else if (line.startsWith("|")) {
inlineState[0] = null;
// Tabellenzeilen sammeln bis die Pipe-Sequenz endet // Tabellenzeilen sammeln bis die Pipe-Sequenz endet
List<String[]> rows = new ArrayList<>(); List<String[]> rows = new ArrayList<>();
while (i < lines.length && lines[i].startsWith("|")) { while (i < lines.length && lines[i].startsWith("|")) {
@@ -185,9 +255,10 @@ public class MarkdownExportService {
if (!rows.isEmpty()) addTable(doc, rows); if (!rows.isEmpty()) addTable(doc, rows);
continue; // i wurde schon inkrementiert continue; // i wurde schon inkrementiert
} else if (!line.isBlank()) { } else if (!line.isBlank()) {
addParagraph(doc, line); addParagraph(doc, line, inlineState);
} else {
inlineState[0] = null; // Leerzeile = Absatzgrenze → Format zurücksetzen
} }
// Leerzeile erzeugt implizit Abstand durch fehlenden Paragraph-Inhalt
i++; i++;
} }
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -215,21 +286,21 @@ public class MarkdownExportService {
run.setFontSize(switch (level) { case 1 -> 22; case 2 -> 18; case 3 -> 14; default -> 12; }); run.setFontSize(switch (level) { case 1 -> 22; case 2 -> 18; case 3 -> 14; default -> 12; });
} }
/** /** Fügt einen normalen Absatz hinzu; state wird für zeilenübergreifende Formatierung mitgeführt. */
* Fügt einen normalen Absatz mit Inline-Formatierung hinzu. private void addParagraph(XWPFDocument doc, String text, SegType[] state) {
* Delegiert an renderInline(), das Bold/Italic/Emoji korrekt als separate Runs setzt. renderInline(doc.createParagraph(), text, state);
*/ }
private void addParagraph(XWPFDocument doc, String text) { private void addParagraph(XWPFDocument doc, String text) {
renderInline(doc.createParagraph(), text); renderInline(doc.createParagraph(), text);
} }
/** /**
* Rendert Inline-Formatierung in einen vorhandenen Paragraph. * Rendert Inline-Formatierung in einen vorhandenen Paragraph.
* parseSegments() liefert die Segmente; jedes bekommt einen eigenen XWPFRun * state wird an parseSegments() weitergereicht und hält das über Zeilen offene Format.
* mit setBold()/setItalic(). Emoji-Codepoints landen als Rohtext (Word rendert sie nativ).
*/ */
private void renderInline(XWPFParagraph p, String text) { private void renderInline(XWPFParagraph p, String text, SegType[] state) {
for (Seg seg : parseSegments(text)) { for (Seg seg : parseSegments(text, state)) {
XWPFRun run = p.createRun(); XWPFRun run = p.createRun();
run.setText(seg.text()); run.setText(seg.text());
if (seg.type() == SegType.BOLD) run.setBold(true); if (seg.type() == SegType.BOLD) run.setBold(true);
@@ -237,6 +308,11 @@ public class MarkdownExportService {
} }
} }
/** Überladung ohne Zustandsverfolgung (für Tabellenzellen). */
private void renderInline(XWPFParagraph p, String text) {
renderInline(p, text, new SegType[]{null});
}
/** /**
* Fügt eine visuelle Trennlinie ein (Dashes-Zeichen, kein echtes OOXML-Border). * Fügt eine visuelle Trennlinie ein (Dashes-Zeichen, kein echtes OOXML-Border).
*/ */
@@ -317,13 +393,14 @@ public class MarkdownExportService {
private static final float TEXT_WIDTH = PAGE_WIDTH - 2 * MARGIN; private static final float TEXT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
/** /**
* Erzeugt ein PDF aus dem Markdown-String. * Erzeugt ein PDF aus dem Markdown-String via PDFBox.
* \r wird vorab entfernt (Oracle CLOBs liefern \r\n; PDType1Font akzeptiert * Emoji werden als farbige Java2D-Icons eingebettet (via PdfWriter.emojiImage()).
* nur Latin-1 0x200xFF, \r würde eine Exception werfen). * \r wird vorab entfernt (Oracle CLOBs liefern \r\n-Zeilenenden).
*/ */
public byte[] toPdf(String markdown) throws Exception { public byte[] toPdf(String markdown) throws Exception {
try (PDDocument doc = new PDDocument()) { try (PDDocument doc = new PDDocument()) {
new PdfWriter(doc).write(markdown.replace("\r", "")); PdfWriter writer = new PdfWriter(doc);
writer.write(markdown.replace("\r", ""));
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos); doc.save(baos);
return baos.toByteArray(); return baos.toByteArray();
@@ -337,6 +414,12 @@ public class MarkdownExportService {
PDPageContentStream cs; // aktiver Content-Stream der aktuellen Seite PDPageContentStream cs; // aktiver Content-Stream der aktuellen Seite
float y; // aktuelle y-Schreibposition (oben nach unten) float y; // aktuelle y-Schreibposition (oben nach unten)
final PDType1Font fNorm, fBold, fItalic; final PDType1Font fNorm, fBold, fItalic;
// Statischer Cache: "codepoint@px" → BufferedImage (JVM-weit, überlebt mehrere PDFs)
private static final java.util.concurrent.ConcurrentHashMap<String, BufferedImage>
BUFFERED_IMAGE_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
// Pro-Dokument-Cache: "codepoint@px" → PDImageXObject (PDDocument-gebunden)
private final java.util.Map<String, PDImageXObject> emojiCache = new java.util.HashMap<>();
int emojiCacheMisses = 0;
PdfWriter(PDDocument doc) throws Exception { PdfWriter(PDDocument doc) throws Exception {
this.doc = doc; this.doc = doc;
@@ -356,6 +439,7 @@ public class MarkdownExportService {
doc.addPage(p); doc.addPage(p);
cs = new PDPageContentStream(doc, p); cs = new PDPageContentStream(doc, p);
y = PAGE_HEIGHT - MARGIN; y = PAGE_HEIGHT - MARGIN;
LOG.infof("PDF: neue Seite %d", doc.getPages().getCount());
} }
/** /**
@@ -390,88 +474,122 @@ public class MarkdownExportService {
PDImageXObject emojiImage(String emoji, float sizePt) throws Exception { PDImageXObject emojiImage(String emoji, float sizePt) throws Exception {
int px = Math.max(24, (int)(sizePt * 3)); int px = Math.max(24, (int)(sizePt * 3));
int pad = Math.max(1, px / 16); int pad = Math.max(1, px / 16);
BufferedImage img = new BufferedImage(px + pad * 2, px + pad * 2, String cacheKey = emoji.codePointAt(0) + "@" + px;
BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_PURE);
float cx = pad + px / 2f; // Level 1: per-document PDImageXObject cache (PDDocument-gebunden)
float cy = pad + px / 2f; PDImageXObject cached = emojiCache.get(cacheKey);
float r = px / 2f - 0.5f; if (cached != null) return cached;
long tEmoji = System.currentTimeMillis();
emojiCacheMisses++;
// Level 2: JVM-weiter BufferedImage-Cache (überlebt mehrere Dokumente)
// Lambda darf keine checked Exceptions werfen → alles per Java2D-Shapes, kein AWT-Font
int cp = emoji.isEmpty() ? 0 : emoji.codePointAt(0); int cp = emoji.isEmpty() ? 0 : emoji.codePointAt(0);
BufferedImage img = BUFFERED_IMAGE_CACHE.computeIfAbsent(cacheKey, k -> {
BufferedImage bi = new BufferedImage(px + pad * 2, px + pad * 2,
BufferedImage.TYPE_INT_ARGB);
Graphics2D g = bi.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
switch (cp) { float cx = pad + px / 2f;
case 0x2705 -> { // ✅ grüner Kreis + weißes Häkchen float cy = pad + px / 2f;
g.setColor(new Color(52, 168, 83)); float r = px / 2f - 0.5f;
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE); switch (cp) {
g.setStroke(new BasicStroke(px / 6f, case 0x2705 -> { // ✅ grüner Kreis + weißes Häkchen
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); g.setColor(new Color(52, 168, 83));
g.drawPolyline( g.fill(new Ellipse2D.Float(pad, pad, px, px));
new int[]{(int)(cx - r*.42f), (int)(cx - r*.08f), (int)(cx + r*.42f)}, g.setColor(Color.WHITE);
new int[]{(int)(cy + r*.05f), (int)(cy + r*.42f), (int)(cy - r*.32f)}, g.setStroke(new BasicStroke(px / 6f,
3); BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.drawPolyline(
new int[]{(int)(cx - r*.42f), (int)(cx - r*.08f), (int)(cx + r*.42f)},
new int[]{(int)(cy + r*.05f), (int)(cy + r*.42f), (int)(cy - r*.32f)},
3);
}
case 0x274C -> { // ❌ roter Kreis + weißes X
g.setColor(new Color(234, 67, 53));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(px / 5.5f,
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
float d = r * 0.34f;
g.drawLine((int)(cx-d),(int)(cy-d),(int)(cx+d),(int)(cy+d));
g.drawLine((int)(cx+d),(int)(cy-d),(int)(cx-d),(int)(cy+d));
}
case 0x26A0 -> { // ⚠️ gelbes Dreieck + ! (Shape, kein AWT-Font)
g.setColor(new Color(251, 188, 4));
g.fillPolygon(
new int[]{(int)cx, pad + px, pad},
new int[]{pad, pad + px, pad + px}, 3);
g.setColor(new Color(50, 50, 50));
// "!" als Shape: Balken (fillRect) + Punkt (Ellipse)
float barW = Math.max(2f, px * 0.10f);
float barH = px * 0.32f;
float dotD = Math.max(2f, px * 0.11f);
g.fill(new java.awt.geom.RoundRectangle2D.Float(
cx - barW / 2f, pad + px * 0.37f, barW, barH, barW / 2, barW / 2));
g.fill(new Ellipse2D.Float(
cx - dotD / 2f, pad + px * 0.77f, dotD, dotD));
}
case 0x2753 -> { // ❓ roter Kreis + weißes ? (Shape, kein AWT-Font)
g.setColor(new Color(234, 67, 53));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE);
float sw = Math.max(1.5f, px / 7.5f);
g.setStroke(new BasicStroke(sw, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// "?" Haken via quadratische Bézierkurven
float x0 = pad, y0 = pad;
java.awt.geom.Path2D.Float path = new java.awt.geom.Path2D.Float();
path.moveTo(x0 + px * 0.28f, y0 + px * 0.26f); // linkes offenes Ende
path.quadTo(x0 + px * 0.26f, y0 + px * 0.12f,
x0 + px * 0.50f, y0 + px * 0.12f); // nach oben-mitte
path.quadTo(x0 + px * 0.74f, y0 + px * 0.12f,
x0 + px * 0.74f, y0 + px * 0.30f); // über die Spitze rechts
path.quadTo(x0 + px * 0.74f, y0 + px * 0.46f,
x0 + px * 0.56f, y0 + px * 0.50f); // rechte Seite einwärts
path.quadTo(x0 + px * 0.50f, y0 + px * 0.52f,
x0 + px * 0.50f, y0 + px * 0.62f); // Schaft nach unten
g.draw(path);
float dotD2 = Math.max(2f, px * 0.10f);
g.fill(new Ellipse2D.Float(
x0 + px * 0.50f - dotD2 / 2f, y0 + px * 0.78f, dotD2, dotD2));
}
case 0x1F4C4 -> { // 📄 blaues Dokument
int dw = (int)(px * .70f), dh = (int)(px * .90f);
int dx = (int)(cx - dw / 2f), dy = pad + (int)(px * .05f);
int fold = (int)(px * .22f);
g.setColor(new Color(66, 133, 244));
g.fillPolygon(
new int[]{dx, dx + dw - fold, dx + dw, dx + dw, dx},
new int[]{dy, dy, dy + fold, dy + dh, dy + dh}, 5);
g.setColor(new Color(30, 90, 200));
g.fillPolygon(
new int[]{dx + dw - fold, dx + dw - fold, dx + dw},
new int[]{dy, dy + fold, dy + fold}, 3);
g.setColor(Color.WHITE);
int lw = (int)(dw * .56f), lx = dx + (int)(dw * .18f);
int lh = Math.max(1, (int)(dh * .08f)), gap = (int)(dh * .13f);
int ly = dy + (int)(dh * .38f);
for (int l = 0; l < 3; l++) g.fillRect(lx, ly + l * gap, lw, lh);
}
default -> { // Unbekannter Codepoint: grauer Kreis (Fallback)
g.setColor(new Color(158, 158, 158));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
}
} }
case 0x274C -> { // ❌ roter Kreis + weißes X g.dispose();
g.setColor(new Color(234, 67, 53)); return bi;
g.fill(new Ellipse2D.Float(pad, pad, px, px)); });
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(px / 5.5f, // BufferedImage → PDImageXObject (verlustfreie PNG-Einbettung)
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); PDImageXObject xobj = LosslessFactory.createFromImage(doc, img);
float d = r * 0.34f; emojiCache.put(cacheKey, xobj);
g.drawLine((int)(cx-d),(int)(cy-d),(int)(cx+d),(int)(cy+d)); LOG.infof("PDF: emojiImage('%s' U+%04X, %dpx) neu gerendert in %dms",
g.drawLine((int)(cx+d),(int)(cy-d),(int)(cx-d),(int)(cy+d)); cacheKey, emoji.codePointAt(0), px, System.currentTimeMillis() - tEmoji);
} return xobj;
case 0x26A0 -> { // ⚠️ gelbes Dreieck + !
g.setColor(new Color(251, 188, 4));
g.fillPolygon(
new int[]{(int)cx, pad + px, pad},
new int[]{pad, pad + px, pad + px}, 3);
g.setColor(new Color(50, 50, 50));
g.setFont(new java.awt.Font("Dialog", java.awt.Font.BOLD, (int)(px * .42f)));
FontMetrics fm = g.getFontMetrics();
g.drawString("!", (int)(cx - fm.stringWidth("!") / 2f),
(int)(pad + px * .82f));
}
case 0x2753 -> { // ❓ roter Kreis + weißes ?
g.setColor(new Color(234, 67, 53));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE);
g.setFont(new java.awt.Font("Dialog", java.awt.Font.BOLD, (int)(px * .55f)));
FontMetrics fm = g.getFontMetrics();
g.drawString("?", (int)(cx - fm.stringWidth("?") / 2f),
(int)(cy + px * .20f));
}
case 0x1F4C4 -> { // 📄 blaues Dokument
int dw = (int)(px * .70f), dh = (int)(px * .90f);
int dx = (int)(cx - dw / 2f), dy = pad + (int)(px * .05f);
int fold = (int)(px * .22f);
g.setColor(new Color(66, 133, 244));
g.fillPolygon(
new int[]{dx, dx + dw - fold, dx + dw, dx + dw, dx},
new int[]{dy, dy, dy + fold, dy + dh, dy + dh}, 5);
g.setColor(new Color(30, 90, 200)); // gefaltete Ecke dunkler
g.fillPolygon(
new int[]{dx + dw - fold, dx + dw - fold, dx + dw},
new int[]{dy, dy + fold, dy + fold}, 3);
g.setColor(Color.WHITE); // drei weiße Textzeilen
int lw = (int)(dw * .56f), lx = dx + (int)(dw * .18f);
int lh = Math.max(1, (int)(dh * .08f)), gap = (int)(dh * .13f);
int ly = dy + (int)(dh * .38f);
for (int l = 0; l < 3; l++) g.fillRect(lx, ly + l * gap, lw, lh);
}
default -> { // Unbekannter Codepoint: grauer Kreis (Fallback)
g.setColor(new Color(158, 158, 158));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
}
}
g.dispose();
// LosslessFactory = verlustfreie PNG-Einbettung ins PDF
return LosslessFactory.createFromImage(doc, img);
} }
/** /**
@@ -551,9 +669,9 @@ public class MarkdownExportService {
* *
* indent: horizontaler Einzug in Punkten (z.B. 20 für Blockquotes). * indent: horizontaler Einzug in Punkten (z.B. 20 für Blockquotes).
*/ */
void renderLine(String text, PDType1Font defaultFont, float size, float indent) void renderLine(String text, PDType1Font defaultFont, float size, float indent,
throws Exception { SegType[] state) throws Exception {
List<Seg> segs = parseSegments(text); List<Seg> segs = parseSegments(text, state);
boolean onlyPlain = segs.stream().allMatch(s -> s.type() == SegType.PLAIN); boolean onlyPlain = segs.stream().allMatch(s -> s.type() == SegType.PLAIN);
if (onlyPlain) { if (onlyPlain) {
writeWrappedText(defaultFont, size, indent, text); writeWrappedText(defaultFont, size, indent, text);
@@ -585,6 +703,12 @@ public class MarkdownExportService {
if (!line.isEmpty()) flushInlineLine(line, size, emojiPt, indent); if (!line.isEmpty()) flushInlineLine(line, size, emojiPt, indent);
} }
/** Überladung ohne Zustandsverfolgung (für Blockquotes, Tabellenzeilen). */
void renderLine(String text, PDType1Font defaultFont, float size, float indent)
throws Exception {
renderLine(text, defaultFont, size, indent, new SegType[]{null});
}
/** /**
* Schreibt eine fertig zusammengestellte Inline-Zeile auf den PDF-Stream. * Schreibt eine fertig zusammengestellte Inline-Zeile auf den PDF-Stream.
* Berechnet die Zeilenhöhe als Maximum aus LINE_HEIGHT und Emoji-Größe, * Berechnet die Zeilenhöhe als Maximum aus LINE_HEIGHT und Emoji-Größe,
@@ -728,8 +852,21 @@ public class MarkdownExportService {
void write(String markdown) throws Exception { void write(String markdown) throws Exception {
String[] lines = markdown.split("\n", -1); String[] lines = markdown.split("\n", -1);
List<String[]> tableAccum = new ArrayList<>(); List<String[]> tableAccum = new ArrayList<>();
int lineNr = 0;
long tWrite = System.currentTimeMillis();
SegType[] inlineState = {null}; // zeilenübergreifendes Bold/Italic-State
for (String line : lines) { for (String line : lines) {
lineNr++;
if (lineNr % 10 == 0) {
LOG.infof("PDF: write() Zeile %d/%d %d Seiten %dms bisher",
lineNr, lines.length, doc.getPages().getCount(),
System.currentTimeMillis() - tWrite);
}
if (lineNr >= lines.length - 20) {
LOG.infof("PDF: Zeile %d: [%s]", lineNr,
line.length() > 120 ? line.substring(0, 120) + "" : line);
}
// Tabellenpuffer flushen sobald eine Nicht-Pipe-Zeile kommt // Tabellenpuffer flushen sobald eine Nicht-Pipe-Zeile kommt
if (!line.startsWith("|") && !tableAccum.isEmpty()) { if (!line.startsWith("|") && !tableAccum.isEmpty()) {
renderTable(tableAccum); renderTable(tableAccum);
@@ -737,14 +874,19 @@ public class MarkdownExportService {
} }
if (line.startsWith("#### ")) { if (line.startsWith("#### ")) {
inlineState[0] = null;
skip(10); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(4); skip(10); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(4);
} else if (line.startsWith("### ")) { } else if (line.startsWith("### ")) {
inlineState[0] = null;
skip(20); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(6); skip(20); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(6);
} else if (line.startsWith("## ")) { } else if (line.startsWith("## ")) {
inlineState[0] = null;
skip(28); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(8); skip(28); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(8);
} else if (line.startsWith("# ")) { } else if (line.startsWith("# ")) {
inlineState[0] = null;
skip(20); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(1); skip(20); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(1);
} else if (line.equals("---")) { } else if (line.equals("---")) {
inlineState[0] = null;
skip(4); skip(4);
ensureSpace(2); ensureSpace(2);
cs.setLineWidth(0.5f); cs.setLineWidth(0.5f);
@@ -753,8 +895,10 @@ public class MarkdownExportService {
cs.stroke(); cs.stroke();
y -= 6; y -= 6;
} else if (line.startsWith("> ")) { } else if (line.startsWith("> ")) {
inlineState[0] = null;
renderLine(line.substring(2).trim(), fItalic, 10, 20); renderLine(line.substring(2).trim(), fItalic, 10, 20);
} else if (line.startsWith("|")) { } else if (line.startsWith("|")) {
inlineState[0] = null;
if (!isTableSeparator(line)) { if (!isTableSeparator(line)) {
String[] parts = line.split("\\|", -1); String[] parts = line.split("\\|", -1);
List<String> cells = new ArrayList<>(); List<String> cells = new ArrayList<>();
@@ -763,13 +907,23 @@ public class MarkdownExportService {
if (!cells.isEmpty()) tableAccum.add(cells.toArray(new String[0])); if (!cells.isEmpty()) tableAccum.add(cells.toArray(new String[0]));
} }
} else if (line.isBlank()) { } else if (line.isBlank()) {
inlineState[0] = null; // Leerzeile = Absatzgrenze → Format zurücksetzen
skip(4); skip(4);
} else { } else {
renderLine(line, fNorm, 11, 0); renderLine(line, fNorm, 11, 0, inlineState);
} }
} }
if (!tableAccum.isEmpty()) renderTable(tableAccum); if (!tableAccum.isEmpty()) {
LOG.infof("PDF: renderTable() wird aufgerufen (%d Zeilen)", tableAccum.size());
renderTable(tableAccum);
LOG.infof("PDF: renderTable() fertig");
}
LOG.infof("PDF: cs.close() wird aufgerufen (Seite %d)", doc.getPages().getCount());
cs.close(); cs.close();
LOG.infof("PDF: cs.close() fertig");
LOG.infof("PDF: write() fertig %d Zeilen, %d Seiten, %dms",
lines.length, doc.getPages().getCount(),
System.currentTimeMillis() - tWrite);
} }
} }
} }

View File

@@ -0,0 +1,260 @@
package de.frigosped.dc.service;
import de.frigosped.dc.model.*;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@ApplicationScoped
public class MarkdownReportBuilder {
private static final String ICON_OK = ""; // ✅
private static final String ICON_NOK = ""; // ❌
private static final String ICON_WARN = "⚠️"; // ⚠️
private static final String ICON_UNKL = ""; // ❓
private static final String ICON_DOC = "📄"; // 📄
private static final DateTimeFormatter DATE_FMT =
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm");
/**
* Generates a Markdown report equivalent to DC_UTILS_PKG.generate_report().
* @param project project meta (name, notify flags, completedAt)
* @param documents ordered list of project documents
* @param questionsByCatalog map catalogId → sorted questions
* @param resultsByDoc map docId → list of saved results
*/
public String build(OrdsProject project,
List<OrdsDocument> documents,
Map<Long, List<OrdsQuestion>> questionsByCatalog,
Map<Long, List<ResultRequest>> resultsByDoc) {
boolean showOk = !"N".equals(project.getNotifyOk());
boolean showWarn = !"N".equals(project.getNotifyHinweis());
boolean showDev = !"N".equals(project.getNotifyAbweichend());
// Flat lookup: questionId → question
Map<Long, OrdsQuestion> questionById = new HashMap<>();
for (List<OrdsQuestion> qs : questionsByCatalog.values()) {
for (OrdsQuestion q : qs) {
questionById.put(q.getQuestionId(), q);
}
}
// Global counts (total_all unfiltered, filtered for summary section)
int totalAll = 0, devCnt = 0, warnCnt = 0, unklarCnt = 0;
for (List<ResultRequest> list : resultsByDoc.values()) {
for (ResultRequest r : list) {
totalAll++;
if (isDeviation(r)) devCnt++;
else if (isWarning(r)) warnCnt++;
else if (isUnklar(r)) unklarCnt++;
}
}
int okCnt = totalAll - devCnt - warnCnt - unklarCnt;
// Filtered counts matching notify flags (same logic as PL/SQL)
int filtTotal = (showDev ? devCnt : 0)
+ (showWarn ? warnCnt : 0)
+ (showOk ? (okCnt + unklarCnt) : 0);
int filtDevCnt = showDev ? devCnt : 0;
int filtWarnCnt = showWarn ? warnCnt : 0;
int filtUnklCnt = showOk ? unklarCnt : 0;
int filtOkCnt = filtTotal - filtDevCnt - filtWarnCnt - filtUnklCnt;
StringBuilder sb = new StringBuilder();
// Header
sb.append("# Prüfbericht: ").append(project.getName()).append("\n\n");
LocalDateTime ts = project.getCompletedAt() != null
? project.getCompletedAt() : LocalDateTime.now();
sb.append("**Abgeschlossen:** ").append(ts.format(DATE_FMT)).append("\n\n");
sb.append("---\n\n");
// Summary
sb.append("## Zusammenfassung\n\n");
if (showOk) {
sb.append(ICON_OK).append(" OK: ").append(filtOkCnt).append("\n");
if (filtUnklCnt > 0) {
sb.append(ICON_UNKL).append(" Unklar: ").append(filtUnklCnt).append("\n");
}
}
if (showWarn) sb.append(ICON_WARN).append(" Hinweise: ").append(filtWarnCnt).append("\n");
if (showDev) sb.append(ICON_NOK).append(" Abweichungen: ").append(filtDevCnt).append("\n");
sb.append("**Gesamt: ").append(totalAll).append("**\n\n");
sb.append("---\n\n");
// Results per document
sb.append("## Ergebnisse\n\n");
for (OrdsDocument doc : documents) {
sb.append("## ").append(ICON_DOC).append(" ").append(doc.getFilename()).append("\n\n");
List<ResultRequest> docResults = resultsByDoc.getOrDefault(doc.getId(), Collections.emptyList());
// Per-doc counts
int dDev = 0, dWarn = 0, dUnkl = 0;
for (ResultRequest r : docResults) {
if (isDeviation(r)) dDev++;
else if (isWarning(r)) dWarn++;
else if (isUnklar(r)) dUnkl++;
}
int dAll = docResults.size();
int dOk = dAll - dDev - dWarn - dUnkl;
if (showOk) {
sb.append(ICON_OK).append(" OK: ").append(dOk).append("\n");
if (dUnkl > 0) sb.append(ICON_UNKL).append(" Unklar: ").append(dUnkl).append("\n");
}
if (showWarn) sb.append(ICON_WARN).append(" Hinweise: ").append(dWarn).append("\n");
if (showDev) sb.append(ICON_NOK).append(" Abweichungen: ").append(dDev).append("\n");
sb.append("**Gesamt: ").append(dAll).append("**\n\n");
sb.append("---\n\n");
// Results filtered + sorted by category order, then question order
List<ResultRequest> visible = docResults.stream()
.filter(r -> isVisible(r, showOk, showWarn, showDev))
.sorted(Comparator
.comparingInt((ResultRequest r) -> catOrder(r, questionById))
.thenComparingInt(r -> qOrder(r, questionById)))
.collect(Collectors.toList());
Long currentCatId = null;
for (ResultRequest r : visible) {
OrdsQuestion q = questionById.get(r.getQuestionId());
if (q == null) continue;
if (!Objects.equals(q.getCategoryId(), currentCatId)) {
currentCatId = q.getCategoryId();
String catName = q.getCategoryName() != null ? q.getCategoryName() : "Allgemein";
sb.append("\n### ").append(catName).append("\n\n");
}
String icon = icon(r);
sb.append("\n\n").append(icon).append(" **").append(q.getQuestionText()).append("**\n\n");
if (r.getAnswer() != null && !r.getAnswer().isBlank()) {
sb.append(r.getAnswer()).append("\n\n");
}
if (r.getScore() != null) {
sb.append("*Score: ").append(r.getScore()).append("%*\n");
}
if (r.getTheComment() != null && !r.getTheComment().isBlank()) {
sb.append("\n> ").append(r.getTheComment()).append("\n");
}
sb.append("\n");
}
sb.append("---\n\n");
}
return sb.toString();
}
/**
* Generates a Markdown report for a single document.
*/
public String buildForDocument(OrdsProject project,
OrdsDocument doc,
Map<Long, OrdsQuestion> questionById,
List<ResultRequest> docResults) {
boolean showOk = !"N".equals(project.getNotifyOk());
boolean showWarn = !"N".equals(project.getNotifyHinweis());
boolean showDev = !"N".equals(project.getNotifyAbweichend());
int dDev = 0, dWarn = 0, dUnkl = 0;
for (ResultRequest r : docResults) {
if (isDeviation(r)) dDev++;
else if (isWarning(r)) dWarn++;
else if (isUnklar(r)) dUnkl++;
}
int dAll = docResults.size();
int dOk = dAll - dDev - dWarn - dUnkl;
StringBuilder sb = new StringBuilder();
sb.append("# Prüfbericht: ").append(project.getName()).append("\n\n");
LocalDateTime ts = project.getCompletedAt() != null
? project.getCompletedAt() : LocalDateTime.now();
sb.append("**Abgeschlossen:** ").append(ts.format(DATE_FMT)).append("\n\n");
sb.append("---\n\n");
sb.append("## ").append(ICON_DOC).append(" ").append(doc.getFilename()).append("\n\n");
if (showOk) {
sb.append(ICON_OK).append(" OK: ").append(dOk).append("\n");
if (dUnkl > 0) sb.append(ICON_UNKL).append(" Unklar: ").append(dUnkl).append("\n");
}
if (showWarn) sb.append(ICON_WARN).append(" Hinweise: ").append(dWarn).append("\n");
if (showDev) sb.append(ICON_NOK).append(" Abweichungen: ").append(dDev).append("\n");
sb.append("**Gesamt: ").append(dAll).append("**\n\n");
sb.append("---\n\n");
List<ResultRequest> visible = docResults.stream()
.filter(r -> isVisible(r, showOk, showWarn, showDev))
.sorted(Comparator
.comparingInt((ResultRequest r) -> catOrder(r, questionById))
.thenComparingInt(r -> qOrder(r, questionById)))
.collect(Collectors.toList());
Long currentCatId = null;
for (ResultRequest r : visible) {
OrdsQuestion q = questionById.get(r.getQuestionId());
if (q == null) continue;
if (!Objects.equals(q.getCategoryId(), currentCatId)) {
currentCatId = q.getCategoryId();
String catName = q.getCategoryName() != null ? q.getCategoryName() : "Allgemein";
sb.append("\n### ").append(catName).append("\n\n");
}
String icon = icon(r);
sb.append("\n\n").append(icon).append(" **").append(q.getQuestionText()).append("**\n\n");
if (r.getAnswer() != null && !r.getAnswer().isBlank()) {
sb.append(r.getAnswer()).append("\n\n");
}
if (r.getScore() != null) {
sb.append("*Score: ").append(r.getScore()).append("%*\n");
}
if (r.getTheComment() != null && !r.getTheComment().isBlank()) {
sb.append("\n> ").append(r.getTheComment()).append("\n");
}
sb.append("\n");
}
sb.append("---\n\n");
return sb.toString();
}
private boolean isDeviation(ResultRequest r) { return Integer.valueOf(1).equals(r.getDeviation()); }
private boolean isWarning(ResultRequest r) { return Integer.valueOf(1).equals(r.getWarning()); }
private boolean isUnklar(ResultRequest r) { return "UNKLAR".equals(r.getResultType()) && !isDeviation(r) && !isWarning(r); }
private boolean isVisible(ResultRequest r, boolean showOk, boolean showWarn, boolean showDev) {
if (isDeviation(r)) return showDev;
if (isWarning(r)) return showWarn;
return showOk;
}
private String icon(ResultRequest r) {
if (isDeviation(r)) return ICON_NOK;
if (isWarning(r)) return ICON_WARN;
if (isUnklar(r)) return ICON_UNKL;
return ICON_OK;
}
private int catOrder(ResultRequest r, Map<Long, OrdsQuestion> byId) {
OrdsQuestion q = byId.get(r.getQuestionId());
return (q != null && q.getCategoryOrderNr() != null) ? q.getCategoryOrderNr() : Integer.MAX_VALUE;
}
private int qOrder(ResultRequest r, Map<Long, OrdsQuestion> byId) {
OrdsQuestion q = byId.get(r.getQuestionId());
return (q != null && q.getOrderNr() != null) ? q.getOrderNr() : Integer.MAX_VALUE;
}
}

View File

@@ -0,0 +1,281 @@
package de.frigosped.dc.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.jsoup.Jsoup;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* Lädt externe Referenz-Dokumente sicher herunter und extrahiert deren Text.
*
* Sicherheit (SSRF): Es werden ausschließlich öffentlich auflösbare Domains
* akzeptiert. Der Hostname wird per DNS aufgelöst und jede resultierende IP
* gegen private/Loopback/Link-Local/CGNAT/ULA-Bereiche geprüft. Redirects
* werden manuell verfolgt und bei jedem Hop neu validiert. Größe und Timeout
* sind begrenzt.
*
* Text-Extraktion:
* - PDF/DOCX/ODT/Plain → {@link DocumentProcessingService#extractText}
* - text/html → jsoup → Text
*/
@ApplicationScoped
public class ReferenceDownloadService {
private static final Logger LOG = Logger.getLogger(ReferenceDownloadService.class);
private static final int MAX_REDIRECTS = 5;
@Inject
DocumentProcessingService documentProcessingService;
@ConfigProperty(name = "dc.references.enabled", defaultValue = "true")
boolean enabled;
@ConfigProperty(name = "dc.references.max-size-bytes", defaultValue = "15000000")
long maxSizeBytes;
@ConfigProperty(name = "dc.references.timeout", defaultValue = "30s")
Duration timeout;
@ConfigProperty(name = "dc.references.allowed-content-types",
defaultValue = "application/pdf,"
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document,"
+ "application/msword,"
+ "application/vnd.oasis.opendocument.text,"
+ "text/plain,text/markdown,text/html")
String allowedContentTypes;
/** Ergebnis eines erfolgreichen Referenz-Downloads. */
public record DownloadedReference(
byte[] bytes, String mimeType, String text, String filename, String sourceUrl) {}
public boolean isEnabled() {
return enabled;
}
/**
* Lädt eine URL herunter und extrahiert den Text. Bei jedem Fehler (ungültige
* URL, SSRF-Blockade, Timeout, zu groß, nicht erlaubter Typ, leerer Text) wird
* {@link Optional#empty()} zurückgegeben die Verarbeitung läuft weiter.
*/
public Optional<DownloadedReference> fetch(String rawUrl, Long projectId) {
if (!enabled) return Optional.empty();
String url = normalize(rawUrl);
if (url == null) {
LOG.debugf("Referenz übersprungen ungültige URL: %s", rawUrl);
return Optional.empty();
}
try {
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(timeout)
.build();
URI current = URI.create(url);
HttpResponse<InputStream> response = null;
for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (!isPublicHost(current.getHost())) {
LOG.warnf("Referenz blockiert (SSRF-Schutz): %s", current);
return Optional.empty();
}
HttpRequest request = HttpRequest.newBuilder(current)
.timeout(timeout)
.header("User-Agent", "Frigosped-DocumentCheck/1.0")
.GET()
.build();
response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
int status = response.statusCode();
if (status >= 300 && status < 400) {
Optional<String> loc = response.headers().firstValue("location");
if (loc.isEmpty()) break;
current = current.resolve(loc.get()); // relativ oder absolut
response.body().close();
continue;
}
break;
}
if (response == null || response.statusCode() != 200) {
LOG.debugf("Referenz übersprungen HTTP %s: %s",
response == null ? "?" : response.statusCode(), url);
return Optional.empty();
}
String contentType = response.headers().firstValue("content-type")
.map(ct -> ct.split(";")[0].trim().toLowerCase())
.orElse("application/octet-stream");
if (!isAllowedType(contentType)) {
LOG.debugf("Referenz übersprungen Content-Type %s nicht erlaubt: %s",
contentType, url);
response.body().close();
return Optional.empty();
}
byte[] bytes = readLimited(response.body(), maxSizeBytes);
if (bytes == null) {
LOG.warnf("Referenz übersprungen größer als %d Bytes: %s", maxSizeBytes, url);
return Optional.empty();
}
String filename = deriveFilename(current, contentType);
String text = extract(bytes, contentType, filename, current.toString(), projectId);
if (text == null || text.isBlank()) {
LOG.debugf("Referenz übersprungen kein Text extrahierbar: %s", url);
return Optional.empty();
}
LOG.infof("Referenz geladen: %s (%s, %d Bytes, %d Zeichen)",
url, contentType, bytes.length, text.length());
return Optional.of(new DownloadedReference(bytes, contentType, text, filename, url));
} catch (Exception e) {
LOG.debugf("Referenz-Download fehlgeschlagen (%s): %s", url, e.getMessage());
return Optional.empty();
}
}
// =========================================================================
// Text-Extraktion
// =========================================================================
private String extract(byte[] bytes, String contentType, String filename,
String url, Long projectId) {
if (contentType.equals("text/html")) {
String html = new String(bytes, StandardCharsets.UTF_8);
return Jsoup.parse(html, url).text();
}
return documentProcessingService.extractText(bytes, contentType, filename, projectId);
}
// =========================================================================
// SSRF-Schutz
// =========================================================================
/**
* true, wenn der Host öffentlich auflösbar ist und KEINE der aufgelösten
* IP-Adressen in einem privaten/internen Bereich liegt.
*/
boolean isPublicHost(String host) {
if (host == null || host.isBlank()) return false;
try {
InetAddress[] addresses = InetAddress.getAllByName(host);
if (addresses.length == 0) return false;
for (InetAddress addr : addresses) {
if (!isPublicAddress(addr)) return false;
}
return true;
} catch (Exception e) {
return false; // nicht auflösbar → blockieren
}
}
private boolean isPublicAddress(InetAddress addr) {
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|| addr.isMulticastAddress()) {
return false;
}
byte[] b = addr.getAddress();
if (b.length == 4) {
int o0 = b[0] & 0xFF, o1 = b[1] & 0xFF;
if (o0 == 0) return false; // 0.0.0.0/8
if (o0 == 127) return false; // Loopback (Sicherheit)
if (o0 == 100 && o1 >= 64 && o1 <= 127) return false; // CGNAT 100.64.0.0/10
if (o0 == 169 && o1 == 254) return false; // Link-Local
} else if (b.length == 16) {
if ((b[0] & 0xFE) == 0xFC) return false; // IPv6 ULA fc00::/7
}
return true;
}
// =========================================================================
// Hilfsmethoden
// =========================================================================
/** Normalisiert eine vom LLM gelieferte URL (trimmt Markdown/Satzzeichen). */
private String normalize(String raw) {
if (raw == null) return null;
String u = raw.trim();
if (u.startsWith("<") && u.endsWith(">")) u = u.substring(1, u.length() - 1).trim();
// typische nachgestellte Satzzeichen entfernen
while (!u.isEmpty() && ").,;\"'".indexOf(u.charAt(u.length() - 1)) >= 0) {
u = u.substring(0, u.length() - 1);
}
if (!u.regionMatches(true, 0, "http://", 0, 7)
&& !u.regionMatches(true, 0, "https://", 0, 8)) {
return null;
}
return u;
}
/** Liest den Stream bis zum Limit; gibt null zurück wenn das Limit überschritten wird. */
private byte[] readLimited(InputStream in, long limit) throws Exception {
try (in) {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
long total = 0;
int n;
while ((n = in.read(buf)) != -1) {
total += n;
if (total > limit) return null;
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
private boolean isAllowedType(String contentType) {
List<String> allowed = Arrays.stream(allowedContentTypes.split(","))
.map(String::trim).map(String::toLowerCase).toList();
return allowed.contains(contentType);
}
private String deriveFilename(URI uri, String contentType) {
String path = uri.getPath();
String name = null;
if (path != null && !path.isBlank()) {
int slash = path.lastIndexOf('/');
String last = slash >= 0 ? path.substring(slash + 1) : path;
if (!last.isBlank()) name = last;
}
if (name == null || name.isBlank()) {
name = (uri.getHost() != null ? uri.getHost() : "referenz") + extensionFor(contentType);
} else if (!name.contains(".")) {
name = name + extensionFor(contentType);
}
// Dateinamen säubern
return name.replaceAll("[^A-Za-z0-9_äöüÄÖÜß.-]", "_");
}
private String extensionFor(String contentType) {
return switch (contentType) {
case "application/pdf" -> ".pdf";
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> ".docx";
case "application/msword" -> ".doc";
case "application/vnd.oasis.opendocument.text" -> ".odt";
case "text/html" -> ".html";
case "text/markdown" -> ".md";
default -> ".txt";
};
}
}

View File

@@ -64,6 +64,14 @@ dc.ai.catalog.temperature=0.1
dc.ai.ocr.timeout=120s dc.ai.ocr.timeout=120s
dc.ai.ocr.max-retries=2 dc.ai.ocr.max-retries=2
# =============================================================================
# Mistral OCR REST Client
# =============================================================================
quarkus.rest-client.mistral-ocr.url=https://api.mistral.ai
quarkus.rest-client.mistral-ocr.scope=jakarta.inject.Singleton
quarkus.rest-client.mistral-ocr.connect-timeout=10000
quarkus.rest-client.mistral-ocr.read-timeout=120000
# ============================================================================= # =============================================================================
# ORDS REST Client # ORDS REST Client
# ============================================================================= # =============================================================================
@@ -71,11 +79,25 @@ dc.ai.ocr.max-retries=2
quarkus.rest-client.ords-api.url=http://ords:8080/ords/ai_dev quarkus.rest-client.ords-api.url=http://ords:8080/ords/ai_dev
quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton
quarkus.rest-client.ords-api.connect-timeout=10000 quarkus.rest-client.ords-api.connect-timeout=10000
quarkus.rest-client.ords-api.read-timeout=60000 quarkus.rest-client.ords-api.read-timeout=600000
# ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev) # ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev)
dc.ords.bearer-token= dc.ords.bearer-token=
# =============================================================================
# Externe Referenz-Dokumente (Links im Dokument → herunterladen & mitbewerten)
# =============================================================================
# Feature an/aus
dc.references.enabled=true
# Max. Anzahl heruntergeladener Referenzen pro Hauptdokument
dc.references.max-per-document=5
# Max. Dateigröße pro Referenz (Bytes); größere Downloads werden verworfen
dc.references.max-size-bytes=15000000
# Verbindungs-/Lese-Timeout pro Download
dc.references.timeout=30s
# Erlaubte Content-Types (alles andere wird verworfen)
dc.references.allowed-content-types=application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.oasis.opendocument.text,text/plain,text/markdown,text/html
# ============================================================================= # =============================================================================
# Logging # Logging
# ============================================================================= # =============================================================================

File diff suppressed because one or more lines are too long

View File

@@ -64,6 +64,14 @@ dc.ai.catalog.temperature=0.1
dc.ai.ocr.timeout=120s dc.ai.ocr.timeout=120s
dc.ai.ocr.max-retries=2 dc.ai.ocr.max-retries=2
# =============================================================================
# Mistral OCR REST Client
# =============================================================================
quarkus.rest-client.mistral-ocr.url=https://api.mistral.ai
quarkus.rest-client.mistral-ocr.scope=jakarta.inject.Singleton
quarkus.rest-client.mistral-ocr.connect-timeout=10000
quarkus.rest-client.mistral-ocr.read-timeout=120000
# ============================================================================= # =============================================================================
# ORDS REST Client # ORDS REST Client
# ============================================================================= # =============================================================================
@@ -71,11 +79,25 @@ dc.ai.ocr.max-retries=2
quarkus.rest-client.ords-api.url=http://ords:8080/ords/ai_dev quarkus.rest-client.ords-api.url=http://ords:8080/ords/ai_dev
quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton quarkus.rest-client.ords-api.scope=jakarta.inject.Singleton
quarkus.rest-client.ords-api.connect-timeout=10000 quarkus.rest-client.ords-api.connect-timeout=10000
quarkus.rest-client.ords-api.read-timeout=60000 quarkus.rest-client.ords-api.read-timeout=600000
# ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev) # ORDS-Authentifizierung (OAuth2 Bearer Token leer = kein Auth in Dev)
dc.ords.bearer-token= dc.ords.bearer-token=
# =============================================================================
# Externe Referenz-Dokumente (Links im Dokument → herunterladen & mitbewerten)
# =============================================================================
# Feature an/aus
dc.references.enabled=true
# Max. Anzahl heruntergeladener Referenzen pro Hauptdokument
dc.references.max-per-document=5
# Max. Dateigröße pro Referenz (Bytes); größere Downloads werden verworfen
dc.references.max-size-bytes=15000000
# Verbindungs-/Lese-Timeout pro Download
dc.references.timeout=30s
# Erlaubte Content-Types (alles andere wird verworfen)
dc.references.allowed-content-types=application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.oasis.opendocument.text,text/plain,text/markdown,text/html
# ============================================================================= # =============================================================================
# Logging # Logging
# ============================================================================= # =============================================================================

View File

@@ -1,25 +1,50 @@
de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedCategory.class de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedCategory.class
de/frigosped/dc/model/MistralOcrRequest$Document.class
de/frigosped/dc/resource/ExportResource.class de/frigosped/dc/resource/ExportResource.class
de/frigosped/dc/model/OrdsQuestionCreateRequest.class de/frigosped/dc/model/DocumentProgressRequest.class
de/frigosped/dc/model/MistralOcrRequest.class
de/frigosped/dc/model/CompleteProjectRequest.class
de/frigosped/dc/model/OrdsCategoryCreateRequest.class de/frigosped/dc/model/OrdsCategoryCreateRequest.class
de/frigosped/dc/ai/AiModelProducer.class
de/frigosped/dc/model/OrdsCatalogCreateRequest.class de/frigosped/dc/model/OrdsCatalogCreateRequest.class
de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.class de/frigosped/dc/model/OrdsDocumentList.class
de/frigosped/dc/service/EvaluationService.class
de/frigosped/dc/service/MarkdownExportService.class
de/frigosped/dc/service/DocumentProcessingService.class de/frigosped/dc/service/DocumentProcessingService.class
de/frigosped/dc/model/CatalogGenerateResponse.class de/frigosped/dc/resource/CheckResource.class
de/frigosped/dc/service/MarkdownExportService$Seg.class de/frigosped/dc/service/MarkdownExportService$Seg.class
de/frigosped/dc/model/AiCostRequest.class
de/frigosped/dc/model/OrdsCatalogStatusResponse.class de/frigosped/dc/model/OrdsCatalogStatusResponse.class
de/frigosped/dc/client/OrdsClient.class
de/frigosped/dc/service/CheckOrchestrationService.class
de/frigosped/dc/model/GeneratedCatalogStructure.class
de/frigosped/dc/client/OrdsLoggingFilter.class
de/frigosped/dc/model/ResultRequest.class
de/frigosped/dc/service/ReferenceDownloadService.class
de/frigosped/dc/service/MarkdownReportBuilder.class
de/frigosped/dc/model/OrdsQuestion.class
de/frigosped/dc/client/MistralOcrClient.class
de/frigosped/dc/model/OrdsQuestionList.class
de/frigosped/dc/model/OrdsQuestionCreateRequest.class
de/frigosped/dc/model/ProgressRequest.class
de/frigosped/dc/model/OrdsDocument.class
de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.class
de/frigosped/dc/model/MistralOcrResponse.class
de/frigosped/dc/service/EvaluationService.class
de/frigosped/dc/model/OrdsProject.class
de/frigosped/dc/service/MarkdownExportService.class
de/frigosped/dc/model/CatalogGenerateResponse.class
de/frigosped/dc/model/TextsRequest.class
de/frigosped/dc/model/EvaluationResult.class
de/frigosped/dc/model/AiCostRequest.class
de/frigosped/dc/model/OrdsDocumentTexts.class de/frigosped/dc/model/OrdsDocumentTexts.class
de/frigosped/dc/model/MistralOcrResponse$UsageInfo.class
de/frigosped/dc/resource/CatalogResource.class de/frigosped/dc/resource/CatalogResource.class
de/frigosped/dc/model/ExportRequest.class de/frigosped/dc/model/ExportRequest.class
de/frigosped/dc/service/CatalogGenerationService.class de/frigosped/dc/service/CatalogGenerationService.class
de/frigosped/dc/service/CheckOrchestrationService.class de/frigosped/dc/model/DocumentPdfEntry.class
de/frigosped/dc/service/MarkdownExportService$SegType.class de/frigosped/dc/service/MarkdownExportService$SegType.class
de/frigosped/dc/model/GeneratedCatalogStructure.class
de/frigosped/dc/client/OrdsLoggingFilter.class
de/frigosped/dc/service/MarkdownExportService$PdfWriter.class de/frigosped/dc/service/MarkdownExportService$PdfWriter.class
de/frigosped/dc/security/ApiKeyFilter.class de/frigosped/dc/security/ApiKeyFilter.class
de/frigosped/dc/service/ReferenceDownloadService$DownloadedReference.class
de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedQuestion.class de/frigosped/dc/model/GeneratedCatalogStructure$GeneratedQuestion.class
de/frigosped/dc/service/AiCostTrackerService.class de/frigosped/dc/service/AiCostTrackerService.class
de/frigosped/dc/model/MistralOcrResponse$Page.class
de/frigosped/dc/model/ReferenceCreateRequest.class

View File

@@ -1,32 +1,41 @@
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/MistralOcrClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/AiCostRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsLoggingFilter.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/CatalogGenerateResponse.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/AiCostRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/CatalogGenerateResponse.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/CompleteProjectRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/GeneratedCatalogStructure.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/DocumentPdfEntry.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogCreateRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/DocumentProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusResponse.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ExportRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCategoryCreateRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/GeneratedCatalogStructure.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/MistralOcrRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/MistralOcrResponse.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentTexts.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogCreateRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusResponse.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCatalogStatusUpdateRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionCreateRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsCategoryCreateRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentTexts.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CatalogResource.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionCreateRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/security/ApiKeyFilter.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/AiCostTrackerService.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ReferenceCreateRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CatalogResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java /home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/ExportResource.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/security/ApiKeyFilter.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/AiCostTrackerService.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CatalogGenerationService.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownExportService.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/MarkdownReportBuilder.java
/home/philipp/git/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/ReferenceDownloadService.java

View File

@@ -1,12 +0,0 @@
de/frigosped/dc/model/OrdsQuestion.class
de/frigosped/dc/model/OrdsQuestionList.class
de/frigosped/dc/model/ProgressRequest.class
de/frigosped/dc/client/OrdsClient.class
de/frigosped/dc/ai/AiModelProducer.class
de/frigosped/dc/model/OrdsDocument.class
de/frigosped/dc/model/OrdsProject.class
de/frigosped/dc/model/OrdsDocumentList.class
de/frigosped/dc/model/TextsRequest.class
de/frigosped/dc/model/ResultRequest.class
de/frigosped/dc/model/EvaluationResult.class
de/frigosped/dc/resource/CheckResource.class

View File

@@ -1,15 +0,0 @@
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/ai/AiModelProducer.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/client/OrdsClient.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/EvaluationResult.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocument.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsDocumentList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsProject.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestion.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/OrdsQuestionList.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ProgressRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/ResultRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/model/TextsRequest.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/resource/CheckResource.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/CheckOrchestrationService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/DocumentProcessingService.java
/mnt/c/source/Frigosped/Dokumenten-Check/dc-backend/src/main/java/de/frigosped/dc/service/EvaluationService.java

21
dc-backend/update.sh Normal file → Executable file
View File

@@ -6,21 +6,36 @@ export KUBECONFIG="$SCRIPT_DIR/../env/frigo-dev.yaml"
TAG="${1:-latest}" TAG="${1:-latest}"
DB_USER="wksp_ai"
DB_PASS="s!)löyx209aasgtrasdasiudkash5235FDSXljLJ"
DB_HOST="10.75.10.171"
DB_PORT="32710"
DB_SID="freepdb1"
DB_CONNECT="${DB_USER}/${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_SID}"
echo "=== dc-backend Update ===" echo "=== dc-backend Update ==="
echo "" echo ""
# --- DB Migration ---
echo "→ [1/4] DB Migration..."
sql -s "$DB_CONNECT" @"$SCRIPT_DIR/../Scripts/DC_DOCUMENT_PROGRESS_ALTER.sql"
sql -s "$DB_CONNECT" @"$SCRIPT_DIR/../Scripts/DC_DOCUMENT_PROGRESS_ORDS.sql"
sql -s "$DB_CONNECT" @"$SCRIPT_DIR/../Scripts/DC_REFERENCES_ALTER.sql"
echo "✓ DB Migration abgeschlossen."
# --- Build & Push --- # --- Build & Push ---
echo "→ [1/3] Build & Push Image (Tag: $TAG)..." echo ""
echo "→ [2/4] Build & Push Image (Tag: $TAG)..."
bash "$SCRIPT_DIR/build-and-push.sh" "$TAG" bash "$SCRIPT_DIR/build-and-push.sh" "$TAG"
# --- Apply Kubernetes Manifests --- # --- Apply Kubernetes Manifests ---
echo "" echo ""
echo "→ [2/3] Kubernetes Manifests anwenden..." echo "→ [3/4] Kubernetes Manifests anwenden..."
kubectl apply -f "$SCRIPT_DIR/k8s/deployment.yaml" kubectl apply -f "$SCRIPT_DIR/k8s/deployment.yaml"
# --- Rolling Restart (zieht neues 'latest'-Image) --- # --- Rolling Restart (zieht neues 'latest'-Image) ---
echo "" echo ""
echo "→ [3/3] Rolling Restart..." echo "→ [4/4] Rolling Restart..."
kubectl rollout restart deployment/dc-backend -n ai-env kubectl rollout restart deployment/dc-backend -n ai-env
echo "" echo ""

0
sqlrun.sh Normal file → Executable file
View File

802
tmp/markdownb64.txt Normal file
View File

@@ -0,0 +1,802 @@
# Prüfbericht: AGB Nordfrost
**Abgeschlossen:** 12.06.2026 15:38
---
## Zusammenfassung
✅ OK: 20
⚠️ Hinweise: 21
❌ Abweichungen: 31
**Gesamt: 72**
---
## Ergebnisse
## 📄 08_Allgemeine_Geschaeftsbedingungen_Transportauftrag_(AGB_Transport).pdf
✅ OK: 20
⚠️ Hinweise: 21
❌ Abweichungen: 31
**Gesamt: 72**
---
### Anwendungsbereich & Ausschlüsse (§2.12.4)
❌ **Sind die ADSP 2017 als maßgebliche AGB im Vertrag ausdrücklich vereinbart?**
Nein. Die ADSp 2017 werden weder erwähnt noch ausdrücklich vereinbart. In Ziffer 30 wird explizit festgehalten, dass die Ziffer 19 ADSp keine Anwendung findet.
*Score: 0%*
> Die ADSp 2017 sind nicht als maßgebliche AGB vereinbart. Der Vertrag enthält stattdessen eigene Regelungen, die die ADSp ausdrücklich ausschließen.
✅ **Ist der Vorrang zwingender gesetzlicher Bestimmungen klarstellend verankert?**
Ja. In Ziffer 6 wird klargestellt, dass die AGB nur gelten, soweit keine zwingenden gesetzlichen Vorschriften oder Rahmenvertragsbestimmungen entgegenstehen.
*Score: 100%*
> Der Vorrang zwingender gesetzlicher Bestimmungen ist in Ziffer 6 explizit verankert, was den Anforderungen entspricht.
⚠️ **Wurde der Ausschlusskatalog (z.B. Schwertransporte, Umzugsgut, Verpackungsarbeiten) bei entsprechender Leistung geprüft?**
Nein. Es gibt keinen Ausschlusskatalog für bestimmte Transportarten (z.B. Schwertransporte, Umzugsgut, Verpackungsarbeiten). Der Vertrag enthält keine entsprechenden Einschränkungen.
*Score: 0%*
> Ein Ausschlusskatalog für spezifische Leistungen fehlt vollständig. Der Vertrag regelt alle Transportarten ohne Einschränkungen.
✅ **Ist die Nichtanwendung auf Verbraucher klarstellend ausgeschlossen bzw. der B2B-Charakter bestätigt?**
Ja. Der Vertrag adressiert ausschließlich gewerbliche Auftragnehmer (B2B). In Ziffer 36 wird deutsches Recht vereinbart, und der B2B-Charakter ergibt sich aus dem Kontext (z.B. Genehmigungspflichten nach GüKG, ISO 50001, IFS Logistics).
*Score: 90%*
> Der B2B-Charakter ist durch den Kontext und die spezifischen Anforderungen (z.B. GüKG, Zertifizierungen) klar erkennbar, auch wenn keine explizite Verbraucherausnahme formuliert ist.
### Informationspflichten & Güterkennzeichnung (§1.3, §1.7, §3.13.4)
❌ **Sind Rohgewicht (inkl. Verpackung und gestellter Lademittel) sowie Anzahl und Art der Packstücke konkret angegeben?**
Nein. Das Dokument enthält keine konkreten Angaben zu Rohgewicht (inkl. Verpackung und gestellter Lademittel), Anzahl oder Art der Packstücke. (<Zitat aus Dokument> Keine direkte Erwähnung dieser Pflichtangaben </Zitat>)
*Score: 0%*
> Die AGB regeln zwar Transportmodalitäten, enthalten aber keine expliziten Vorgaben zur Dokumentation von Rohgewicht, Packstückanzahl oder -art. Dies ist für die Erfüllung der Informationspflichten (§1.3, §1.7, §3.13.4) essenziell.
❌ **Sind bei gefährlichen Gütern die genaue Gefahrart, die Klassifizierung nach Gefahrgutrecht und erforderliche Vorsichtsmaßnahmen dokumentiert?**
Ja, teilweise. Bei Gefahrgut wird die Einhaltung der GGVS/ADR-Bestimmungen und die Mitführung der erforderlichen Schulungsbescheinigung des Fahrers verlangt. (<Zitat aus Dokument> 'Bei Annahme von Aufträgen über die Beförderung von Gefahrgut, hat der Auftragnehmer sicherzustellen, dass das eingesetzte Fahrzeug gemäß den GGVS/ADR-Bestimmungen vollständig ausgerüstet ist und der Fahrer im Besitz der erforderlichen Schulungsbescheinigung ist und diese mit sich führt.' </Zitat>)
*Score: 70%*
> Die AGB verweisen auf die Einhaltung der GGVS/ADR-Bestimmungen, was die Klassifizierung und Vorsichtsmaßnahmen impliziert. Allerdings fehlt eine explizite Dokumentationspflicht der genauen Gefahrart im Transportauftrag selbst.
❌ **Ist wertvolles Gut (≥100 EUR/kg) oder diebstahlgefährdetes Gut mit expliziter Risiko- und Wertangabe deklariert?**
Nein. Es gibt keine Regelung zur Deklaration von wertvollem Gut (≥100 EUR/kg) oder diebstahlgefährdetem Gut mit expliziter Risiko- und Wertangabe. (<Zitat aus Dokument> Keine Erwähnung von Wertdeklarationen oder Risikoangaben </Zitat>)
*Score: 0%*
> Die AGB enthalten keine Vorgaben zur Kennzeichnung oder Deklaration von wertvollem oder diebstahlgefährdetem Gut, obwohl dies für die Risikominimierung und Haftungsfragen relevant wäre.
⚠️ **Ist die Vollständigkeit der zoll-, außenwirtschafts- und sicherheitsrelevanten Daten (Embargos, SOLAS, Schutzrechte) geprüft?**
Nein. Es gibt keine Hinweise auf eine Prüfung der Vollständigkeit zoll-, außenwirtschafts- oder sicherheitsrelevanter Daten (z.B. Embargos, SOLAS, Schutzrechte). (<Zitat aus Dokument> Keine Erwähnung von Zoll-, Embargo- oder SOLAS-Prüfungen </Zitat>)
*Score: 0%*
> Die AGB regeln zwar Sicherheits- und Hygienevorgaben, enthalten aber keine expliziten Anforderungen zur Prüfung zollrechtlicher, außenwirtschaftlicher oder sicherheitsrelevanter Daten.
❌ **Sind alle für die zollamtliche oder sicherheitsrechtliche Behandlung notwendigen Urkunden und Unterlagen beigefügt?**
Nein. Die AGB enthalten keine explizite Regelung zu zollamtlichen oder sicherheitsrechtlichen Urkunden/Unterlagen (z.B. Carnet ATA, Zollpapiere, Sicherheitsbescheinigungen). Lediglich in Ziffer 31 wird die 'Gelangenheitsbescheinigung' bei innergemeinschaftlichem Verkehr erwähnt, aber dies deckt nicht alle notwendigen Unterlagen ab.
*Score: 20%*
> Die AGB sind unvollständig, da sie keine allgemeine Pflicht zur Beifügung aller zoll-/sicherheitsrelevanten Dokumente vorsehen. Nur ein Teilaspekt (Gelangenheitsbescheinigung) wird geregelt.
### Technische Ausstattung & Fahrzeugstandards (§4.2)
✅ **Sind die eingesetzten Fahrzeuge und Lademittel als technisch einwandfrei und gesetzeskonform bestätigt?**
Ja, teilweise. Die AGB fordern in Ziffer 2 die Einhaltung gesetzlicher Genehmigungen (§§ 3, 6 GüKG, ADR, ATP-Zertifikat etc.) und in Ziffer 4/5 spezifische technische Anforderungen (z.B. Wegfahrsperre, ATP-Zertifikat, Kühlaggregat). Allerdings fehlt eine pauschale Bestätigung der technischen Einwandfreiheit aller Fahrzeuge.
*Score: 85%*
> Die AGB enthalten konkrete Vorgaben zu Genehmigungen und technischen Standards, erfüllen aber nicht alle Aspekte einer vollständigen Bestätigung der technischen Einwandfreiheit.
✅ **Sind Vorrichtungen und Ladungssicherungsmittel zum Schutz des Gutes spezifiziert?**
Ja. In Ziffer 15 werden Ladungssicherungsmittel explizit genannt (Spanngurte, Antirutschmatten, Kantenschoner etc.) und die Verantwortung des Auftragnehmers für betriebssichere Verladung betont. Zudem werden Anforderungen an die Stauung (Luftumwälzung, Abstand zu Türen) geregelt.
*Score: 100%*
> Die AGB spezifizieren detailliert Ladungssicherungsmittel und -verfahren, sodass die Anforderung vollständig erfüllt ist.
⚠️ **Ist die Anforderung an schadstoffarme, lärmreduzierte und energiesparende Fahrzeuge enthalten oder erfüllt?**
Nein. Die AGB enthalten keine Anforderungen an schadstoffarme, lärmreduzierte oder energiesparende Fahrzeuge (z.B. Euro-Normen, Lärmgrenzwerte, CO₂-Einsparung). Lediglich in Ziffer 3 wird ein Energiemanagementsystem nach ISO 50001 'begrüßt', aber nicht verbindlich vorgeschrieben.
*Score: 0%*
> Die AGB erfüllen diese Anforderung nicht, da keine konkreten technischen oder ökologischen Standards für Fahrzeuge vorgegeben werden.
### Personal, Sicherheit & Zollabwicklung (§1.11, §4.1, §4.34.6)
✅ **Ist die Bereitstellung von fachlich geschultem Personal und erforderlichen Fahrerbescheinigungen bestätigt?**
Ja, der Auftragnehmer hat sicherzustellen, dass das zum Transport eingesetzte Personal über die notwendigen Fahrerlaubnisse, sonstige vorgeschriebene und vereinbarte Erlaubnisse sowie die notwendige körperliche und geistige Eignung und Zuverlässigkeit verfügt (§18). Ferner wird die Schulung im Umgang mit Temperaturmessgeräten und die Ausstattung mit kältetauglicher Sicherheitskleidung bestätigt.
*Score: 95%*
> Die AGB enthalten klare Vorgaben zur Qualifikation des Personals und zu Fahrerbescheinigungen. Die Anforderungen gehen über das gesetzliche Mindestmaß hinaus.
⚠️ **Wird die Beachtung bekanntgemachter Haus-, Betriebs- oder Baustellenordnungen fremder Betriebsgelände bestätigt?**
Nein, eine explizite Bestätigung zur Beachtung fremder Haus-, Betriebs- oder Baustellenordnungen ist in den AGB nicht enthalten.
*Score: 0%*
> Die AGB regeln keine spezifischen Pflichten zur Einhaltung fremder Betriebsordnungen. Dies wäre jedoch in der Praxis relevant, insbesondere bei Kundenstandorten.
❌ **Liegt eine schriftliche Vollmacht zur direkten zollamtlichen Vertretung vor?**
Nein, eine schriftliche Vollmacht zur direkten zollamtlichen Vertretung wird in den AGB nicht erwähnt.
*Score: 0%*
> Die AGB enthalten keine Regelung zur zollamtlichen Vertretung. Dies ist insbesondere bei grenzüberschreitenden Transporten problematisch.
⚠️ **Ist die Ermächtigung zum Öffnen von Verpackungen und Auslegen von Abgaben bei grenzüberschreitender Beförderung geregelt?**
Nein, die Ermächtigung zum Öffnen von Verpackungen oder Auslegen von Abgaben bei grenzüberschreitender Beförderung ist in den AGB nicht geregelt.
*Score: 0%*
> Die AGB enthalten keine spezifischen Regelungen zu zollrechtlichen Befugnissen. Dies könnte zu Verzögerungen oder Haftungsrisiken führen.
✅ **Ist die Prüfung auf offensichtliche Mängel und die unverzügliche Anzeige bekannter Gefahrumstände dokumentiert?**
Ja, die Prüfung auf offensichtliche Mängel ist dokumentiert: In Ziffer 9 (Kontrolle Ware) ist der Auftragnehmer verpflichtet, bei Übernahme der Güter diese auf Vollständigkeit, Unversehrtheit und Identität zu überprüfen und dies auf dem Frachtbrief zu vermerken. Zudem ist in Ziffer 12 (Meldung Abweichungen) geregelt, dass bei Unstimmigkeiten oder Abweichungen die Verladung einzustellen und unverzüglich Weisungen einzuholen ist. Die unverzügliche Anzeige bekannter Gefahrumstände ist in Ziffer 14 (Gefahrgut) und Ziffer 25 (Meldung Abweichung Transport) enthalten, wo bei Gefahrgut oder Transporthindernissen unverzüglich zu informieren und Weisungen einzuholen ist.
*Score: 95%*
> Die Anforderungen sind klar und umfassend geregelt. Die Dokumentationspflichten sind explizit in den Ziffern 9 und 12 verankert, die unverzügliche Meldung von Gefahrumständen in Ziffern 14 und 25.
### Informations- und Meldepflichten
✅ **Ist im Dokument bei Güter- oder Verspätungsschaden eine Verpflichtung des Spediteurs zur unverzüglichen Verschaffung aller bekannten Informationen zur Sicherung von Schadensersatzansprüchen enthalten?**
Ja, das Dokument enthält eine solche Verpflichtung: In Ziffer 24 (Meldung Verzögerung) ist geregelt, dass der Auftragnehmer bei absehbarer Nichteinhaltung von Lade- oder Lieferfristen unverzüglich zu informieren und Weisungen einzuholen hat. In Ziffer 25 (Meldung Abweichung Transport) ist die unverzügliche Information bei Transporthindernissen oder Unregelmäßigkeiten vorgesehen. Zudem ist in Ziffer 26 (Meldung Abweichung Ware) die unverzügliche Information bei Differenzen, Beschädigungen oder Temperaturabweichungen geregelt.
*Score: 90%*
> Die Meldepflichten sind detailliert und klar formuliert. Die Pflicht zur unverzüglichen Information und Einholung von Weisungen ist in mehreren Ziffern (24, 25, 26) verankert.
⚠️ **Benennen beide Vertragsparteien im Dokument explizit Kontaktpersonen für die Vertragsabwicklung und ist eine Aktualisierungspflicht bei Änderungen festgelegt?**
Nein, das Dokument benennt keine expliziten Kontaktpersonen für die Vertragsabwicklung. Es gibt zwar in Ziffer 4 (Hygiene und Food defense) die Verpflichtung, dem Auftraggeber den Namen und die Nummer des Mobiltelefons des Fahrers mitzuteilen, jedoch keine allgemeine Regelung zu Kontaktpersonen oder einer Aktualisierungspflicht bei Änderungen.
*Score: 20%*
> Es fehlt eine explizite Benennung von Kontaktpersonen oder eine Aktualisierungspflicht. Lediglich die Erreichbarkeit des Fahrers ist geregelt, nicht jedoch die Vertragsparteien selbst.
✅ **Sieht das Dokument vor, dass Leistungshindernisse oder höhere Gewalt unverzüglich gemeldet werden müssen und beide Parteien davon befreit sind?**
Ja, das Dokument sieht eine unverzügliche Meldung von Leistungshindernissen oder höherer Gewalt vor: In Ziffer 25 (Meldung Abweichung Transport) ist geregelt, dass Transporthindernisse oder Unregelmäßigkeiten unverzüglich zu melden sind und Weisungen einzuholen sind. Zudem ist in Ziffer 12 (Meldung Abweichungen) und Ziffer 26 (Meldung Abweichung Ware) die unverzügliche Information bei Abweichungen oder Unstimmigkeiten vorgesehen. Eine Befreiung von der Leistungspflicht bei höherer Gewalt ist nicht explizit genannt, aber die Meldepflicht ist klar geregelt.
*Score: 85%*
> Die Meldepflicht ist klar formuliert, eine explizite Befreiung bei höherer Gewalt fehlt jedoch. Die Anforderungen sind dennoch überwiegend erfüllt.
⚠️ **Ist im Dokument eine Verpflichtung des Spediteurs zur Auskunft über den Geschäftsstand und zur Rechenschaftslegung nach Auftragsausführung festgelegt?**
Nein, eine explizite Verpflichtung zur Auskunft über den Geschäftsstand oder Rechenschaftslegung nach Auftragsausführung ist nicht festgelegt. Lediglich die Dokumentation und Vorlage von Frachtbriefen, Temperaturaufzeichnungen und Transportaufträgen (Ziffer 28) sowie die Meldung von Abweichungen (z. B. Ziffer 24, 25, 26) sind geregelt.
*Score: 0%*
> Die AGB enthalten keine direkte Pflicht zur laufenden Auskunft oder Rechenschaftslegung. Dokumentationspflichten beziehen sich auf die Nachweisführung, nicht auf proaktive Informationspflichten.
✅ **Ist im Dokument festgelegt, dass der Spediteur bei Fehlbeständen oder zu befürchteten Gutveränderungen den Auftraggeber unverzüglich zu informieren hat?**
Ja, der Spediteur ist verpflichtet, bei Fehlbeständen, Beschädigungen, Temperaturabweichungen oder sonstigen Unstimmigkeiten den Auftraggeber unverzüglich zu informieren und Weisungen einzuholen (z. B. Ziffer 9, 12, 26).
*Score: 100%*
> Die AGB enthalten klare Meldepflichten bei Abweichungen, die den Auftraggeber unverzüglich zu informieren und Weisungen einzuholen vorschreiben.
### Leistungsumfang, Weisungen & Vertragsgegenstand
❌ **Schließt das Dokument die Gestellung von Lademitteln, Ver-/Entladung, Sendungsverfolgung und Retouren mangels ausdrücklicher Vereinbarung vom Leistungsumfang aus?**
Nein, das Dokument schließt Lademittelgestellung oder Retouren nicht explizit aus. Die Gestellung von Lademitteln (z. B. Palettentausch, Ziffer 29) und die Dokumentation von Rücknahmen (Ziffer 26) sind sogar geregelt. Sendungsverfolgung und Verladung sind hingegen nicht ausdrücklich ausgeschlossen, sondern Teil der Leistungspflichten (z. B. Ziffer 9, 15).
*Score: 0%*
> Die AGB enthalten keine explizite Ausschlussklausel für Lademittel oder Retouren. Im Gegenteil: Palettentausch und Rücknahmepflichten sind geregelt.
✅ **Bedürfen weitergehende Leistungs- und Informationspflichten (z. B. Audits, Monitoring) im Dokument der ausdrücklichen Vereinbarung?**
Ja, weitergehende Leistungs- und Informationspflichten wie Audits oder Monitoring bedürfen der ausdrücklichen Vereinbarung (z. B. Ziffer 7: IFS Logistics/BRCGS/QS nur bei Vereinbarung; Ziffer 20: Subunternehmer nur mit Zustimmung).
*Score: 100%*
> Die AGB sehen vor, dass zusätzliche Pflichten (z. B. Zertifizierungen, Subunternehmer) nur bei ausdrücklicher Vereinbarung gelten. Dies entspricht dem Grundsatz der Vertragsfreiheit.
⚠️ **Ist im Dokument geregelt, dass Erklärungen des Lager- und Fahrpersonals zur Wirksamkeit der Genehmigung der Vertragspartei bedürfen?**
Nein, das Dokument enthält keine Regelung, dass Erklärungen des Lager- oder Fahrpersonals zur Wirksamkeit der Genehmigung der Vertragspartei bedürfen.
*Score: 0%*
> Die AGB regeln zwar Genehmigungspflichten (z.B. in Ziffer 2), aber nicht die Wirksamkeit von Erklärungen des Personals. Eine solche Klausel fehlt vollständig.
✅ **Ist im Dokument die Pflicht des Spediteurs zur Beachtung von Weisungen sowie zur unverzüglichen Benachrichtigung bei Nichtbefolgung festgelegt?**
Ja, das Dokument enthält in mehreren Ziffern (z.B. Ziffer 9, 12, 24, 25) die Pflicht des Auftragnehmers, bei Abweichungen oder Unstimmigkeiten unverzüglich Weisungen einzuholen und uns zu benachrichtigen. Beispiel: Ziffer 9: 'Bei Differenzen [...] hat er die Verladung einzustellen und uns unverzüglich zu benachrichtigen und von uns Weisungen einzuholen.'
*Score: 100%*
> Die AGB enthalten klare und umfassende Regelungen zur Beachtung von Weisungen und zur unverzüglichen Benachrichtigung bei Nichtbefolgung.
❌ **Wird im Dokument klargestellt, dass Frachtüberweisungs- oder Nachnahmemweisungen die Zahlungspflicht des Auftraggebers für Vergütung und Aufwendungen unberührt lassen?**
Nein, das Dokument enthält keine Regelung, die klarstellt, dass Frachtüberweisungs- oder Nachnahmeanweisungen die Zahlungspflicht des Auftraggebers für Vergütung und Aufwendungen unberührt lassen.
*Score: 0%*
> Die AGB regeln zwar die Vergütung (z.B. Ziffer 22, 30), aber nicht explizit den Einfluss von Frachtüberweisungs- oder Nachnahmeanweisungen auf die Zahlungspflicht.
### Dokumentenführung, EDI & Quittungen
⚠️ **Regelt das Dokument bei EDI-Vereinbarung die betriebsbereiten IT-Systeme, Sicherheitsmaßnahmen gegen unbefugten Zugriff sowie die Pflicht zur rechtzeitigen Mitteilung von Systemänderungen?**
Nein, das Dokument enthält keine Regelungen zu EDI-Vereinbarungen, betriebsbereiten IT-Systemen, Sicherheitsmaßnahmen gegen unbefugten Zugriff oder der Pflicht zur Mitteilung von Systemänderungen.
*Score: 0%*
> Die AGB behandeln keine elektronischen Datenverarbeitungsprozesse oder EDI-spezifische Anforderungen. Solche Klauseln fehlen vollständig.
⚠️ **Stellen elektronisch oder digital erstellte Dokumente im Dokument schriftlichen Dokumenten gleich und ist die elektronische Archivierung mit Vernichtung der Originale erlaubt?**
Das Dokument enthält keine explizite Regelung zur Gleichstellung elektronischer/digitaler Dokumente mit schriftlichen Dokumenten oder zur elektronischen Archivierung mit Vernichtung der Originale. Es wird in Ziffer 28 lediglich die Übersendung von Originaldokumenten (Frachtbriefe, Temperaturaufzeichnungen, Transportauftrag) verlangt.
*Score: 0%*
> Die Anforderungen an digitale Dokumente und Archivierung sind nicht geregelt. Dies ist ein kritischer Mangel, da moderne Transportprozesse zunehmend digital ablaufen.
❌ **Begrenzt die Übernahmquittung im Dokument im Zweifel die Bestätigung auf Anzahl und Art der Packstücke und regelt sie die Widerlegbarkeit bei geschlossenen Ladeeinheiten?**
Die Übernahmquittung wird in Ziffer 9 erwähnt, jedoch ohne explizite Begrenzung auf Anzahl und Art der Packstücke oder Regelung zur Widerlegbarkeit bei geschlossenen Ladeeinheiten. Es wird lediglich die Unterzeichnung des Frachtbriefs durch Fahrer und Ladestelle verlangt.
*Score: 30%*
> Die Regelung ist unpräzise und entspricht nicht den Anforderungen an eine klare Übernahmebestätigung. Eine detailliertere Regelung wäre erforderlich.
❌ **Fordert das Dokument vom Spediteur die Einholung einer Ablieferungsquittung und listet es anerkannte Dokumententypen (z. B. Lieferscheine, Konnossemente) auf?**
In Ziffer 26 wird die Einholung einer Ablieferungsquittung verlangt, die Vollständigkeit, Unversehrtheit, Temperatur und Ablieferungszeitpunkt bestätigt. Anerkannte Dokumententypen wie Lieferscheine oder Frachtbriefe werden genannt, jedoch keine explizite Liste weiterer anerkannter Dokumente (z. B. Konnossemente).
*Score: 70%*
> Die Anforderung ist teilweise erfüllt, aber eine explizite Aufzählung anerkannter Dokumententypen fehlt. Dies könnte zu Unsicherheiten führen.
⚠️ **Ermöglicht das Dokument die elektronische oder digitale Erstellung von Übernahme- oder Ablieferungsquittungen, sofern nicht ausdrücklich Frachtbriefe oder Konnossemente verlangt werden?**
Das Dokument sieht in Ziffer 28 die Übersendung von Originaldokumenten vor und enthält keine Regelung zur elektronischen oder digitalen Erstellung von Übernahme- oder Ablieferungsquittungen. Es wird ausdrücklich die Übersendung von Originalen verlangt.
*Score: 0%*
> Elektronische oder digitale Quittungen sind nicht vorgesehen. Dies entspricht nicht modernen Anforderungen an digitale Prozesse.
### Ladungssicherung, Kontrollen, Fristen & Ablieferung
❌ **Trägt der Auftraggeber im Dokument die Pflicht zur verpackungsgerechten Gestaltung, haltbaren Kennzeichnung und erkennbaren Zusammengehörigkeit der Packstücke?**
Nein. Das Dokument enthält keine Regelung zur verpackungsgerechten Gestaltung, haltbaren Kennzeichnung oder erkennbaren Zusammengehörigkeit der Packstücke.
*Score: 0%*
> Die AGB behandeln primär Transportbedingungen, Hygiene, Temperaturkontrolle und Dokumentation, nicht jedoch Verpackungsvorgaben oder Packstückkennzeichnung.
❌ **Ist im Dokument die durchgehende Ladungssicherung bis zur letzten Entladestelle sowie die Pflicht zu Kontrollen auf Vollzähligkeit, Identität und äußerliche Schäden an jeder Schnittstelle festgelegt?**
Ja, teilweise. In Ziffer 15 (Ladungssicherung) ist die Verantwortung für die beförderungssichere Verladung nach § 412 HGB und VDI 2700 ff geregelt. Kontrollen auf Vollzähligkeit, Identität und Schäden sind in Ziffer 9 (Kontrolle Ware) für die Übernahme festgelegt, jedoch nicht explizit für jede Schnittstelle bis zur letzten Entladestelle.
*Score: 70%*
> Die Ladungssicherung ist detailliert geregelt, aber die durchgehende Kontrolle bis zur letzten Entladestelle ist nicht explizit erwähnt. Die Übernahmekontrolle ist jedoch klar definiert.
❌ **Regelt das Dokument die maximale Lade-/Entladezeit von 2 Stunden für 40t-Komplettladungen, den Beginn mit der Ankunft, das Ende bei Erfüllung der Pflichten sowie die Standgeldpflicht bei Überschreitung?**
Nein. Das Dokument enthält keine Regelung zu einer maximalen Lade-/Entladezeit von 2 Stunden für 40t-Komplettladungen oder zu Standgeldpflichten bei Überschreitung.
*Score: 0%*
> Die AGB regeln zwar Meldungen bei Verzögerungen (Ziffer 24) und Standzeiten (Ziffer 27), aber keine konkreten Zeitvorgaben oder Standgeldregelungen.
❌ **Sieht das Dokument vor, dass bei Nichtantreffen des Empfängers eine Ablieferung an erwachsene Familienangehörige oder ermächtigte Personen erfolgt und die Ablieferung nur unter Aufsicht erfolgen darf?**
Nein. Das Dokument enthält keine Regelung zur Ablieferung an Familienangehörige oder ermächtigte Personen bei Nichtantreffen des Empfängers oder zur Ablieferung unter Aufsicht.
*Score: 0%*
> Die AGB regeln die Dokumentation der Ablieferung (Ziffer 26) und Quittierungspflichten, aber keine spezifischen Ablieferungsmodalitäten bei Abwesenheit des Empfängers.
### Lagerbedingungen & Bestandsführung
❌ **Ist im Dokument geregelt, dass der Spediteur bei Einlagerung bei einem fremden Lagerhalter den Namen und Ort unverzüglich schriftlich bekannt geben muss?**
Nein, eine solche Regelung ist im Dokument nicht enthalten.
*Score: 0%*
> Das Dokument enthält keine Bestimmungen zur Bekanntgabe von Namen und Ort eines fremden Lagerhalters bei Einlagerung.
✅ **Begrenzt sich die Pflicht zur Sicherung des Gutes im Dokument auf die gesetzlichen Brandschutzvorschriften, es sei denn, weitergehende Maßnahmen sind ausdrücklich vereinbart?**
Nein, die Pflicht zur Sicherung des Gutes geht über gesetzliche Brandschutzvorschriften hinaus. Es werden spezifische Anforderungen wie Verschlussvorrichtungen, GPS, Alarmanlagen, Wegfahrsperren, Kraftstoffunterbrechung, Verplombung und Königszapfenschloss genannt (vgl. Ziffer 4).
*Score: 100%*
> Die Anforderungen an die Gutsicherung sind detailliert und gehen über gesetzliche Brandschutzvorschriften hinaus.
❌ **Beginnt die Lagerübernahme im Dokument mit der Entladung des Fahrzeugs, endet sie mit der Verladung, und ist eine jährliche physische Inventur vorgesehen?**
Nein, das Dokument regelt keine Lagerübernahme im Sinne einer Einlagerung bei einem fremden Lagerhalter. Es werden zwar Kontrollen bei Übernahme und Transport geregelt (z. B. Ziffer 9, 11, 26), aber keine Lagerbedingungen oder Inventurvorschriften.
*Score: 0%*
> Das Dokument enthält keine Regelungen zu Lagerübernahme, Verladung oder Inventur.
✅ **Sieht das Dokument eine Eingangskontrolle nach Art, Menge und Beschaffenheit bei Übernahme sowie regelmäßige Kontrollen durch geeignetes Personal zur Gutsicherung vor?**
Ja, das Dokument sieht eine Eingangskontrolle bei Übernahme vor (z. B. Ziffer 9: Prüfung auf Vollständigkeit, Unversehrtheit und Identität; Ziffer 11: Temperaturkontrolle mit kalibriertem Stechthermometer). Regelmäßige Kontrollen werden ebenfalls gefordert (z. B. Ziffer 10: Temperaturkontrolle während des Transports; Ziffer 18: Hygieneschulungen).
*Score: 100%*
> Die Anforderungen an Eingangskontrollen und regelmäßige Kontrollen sind klar geregelt.
### Vergütung, Aufwendungen & Abrechnung
❌ **Ist die vereinbarte Vergütung als Pauschalpreis für Beförderung und Lagerung ausgelegt, sodass gesonderte Nachforderungen für vorhersehbare Kosten ausgeschlossen sind?**
Nein. Die AGB regeln in Ziffer 22 nur, dass die Frachtvergütung die gesamte Ladefläche umfasst und kurzfristige Beiladungen im Frachtpreis enthalten sind. Eine ausdrückliche Regelung, dass gesonderte Nachforderungen für vorhersehbare Kosten ausgeschlossen sind, fehlt.
*Score: 30%*
> Die AGB enthalten keine klare Pauschalpreisvereinbarung für Beförderung und Lagerung. Gesonderte Nachforderungen sind nicht explizit ausgeschlossen.
⚠️ **Ist der Anspruch auf Ersatz notwendiger Aufwendungen wie Detention- oder Demurrage-Kosten sowie Nachverpackungen zum Gutschutz geregelt?**
Nein. Die AGB regeln in Ziffer 27 zwar Standzeiten und deren Dokumentation, aber es gibt keine explizite Regelung zu Detention- oder Demurrage-Kosten oder Nachverpackungen zum Gutschutz.
*Score: 20%*
> Die AGB enthalten keine spezifischen Regelungen zu Ersatzansprüchen für Detention-, Demurrage-Kosten oder Nachverpackungen.
❌ **Ist der Spediteur berechtigt, fremde Abgaben (Zölle, Steuern, Frachten) vorzulegen und der Auftraggeber zur Erstattung und Freistellung verpflichtet?**
Nein. Die AGB regeln in Ziffer 30 nur, dass die Frachtvergütung alle Kosten des Auftragnehmers abgilt, außer Standzeiten. Eine ausdrückliche Regelung zu fremden Abgaben wie Zöllen oder Steuern fehlt.
*Score: 10%*
> Die AGB enthalten keine Regelung zur Vorlage und Erstattung fremder Abgaben wie Zölle oder Steuern durch den Spediteur.
✅ **Erfordert die Fälligkeit der Vergütung den Zugang einer den gesetzlichen Anforderungen genügenden Rechnung oder Zahlungsaufstellung?**
Ja. In Ziffer 30 ist geregelt, dass die Rechnungen des Auftragnehmers 30 Tage nach Zugang der Rechnungen und der beigefügten Originalempfangsquittungen, Temperaturaufzeichnungen und Kopie des Transportauftrags zur Zahlung fällig sind. Dies entspricht den gesetzlichen Anforderungen an eine ordnungsgemäße Rechnung.
*Score: 100%*
> Die Fälligkeit der Vergütung ist an den Zugang einer den gesetzlichen Anforderungen genügenden Rechnung geknüpft, wie in Ziffer 30 geregelt.
⚠️ **Ist die Zahlung in Euro oder fremder Währung erlaubt und die Euro-Umrechnung an einen nachweisbaren amtlichen Kurs zum Zahlungstag gebunden?**
Die Zahlung ist in Euro erlaubt. Eine explizite Bindung an einen amtlichen Kurs zum Zahlungstag ist nicht geregelt (Ziffer 30: 'Die Rechnungen des Auftragnehmers sind 30 Tage nach Zugang der Rechnungen [...] zur Zahlung fällig.').
*Score: 30%*
> Die AGB enthalten keine Regelung zur Währungsumrechnung oder Bindung an einen amtlichen Kurs. Dies ist für grenzüberschreitende Transporte relevant, um Wechselkursrisiken zu minimieren.
⚠️ **Ist das Gutschriftenverfahren explizit vereinbart oder wird im Zweifel die sofortige Erteilung von Gutschriften nach Leistungserbringung vorausgesetzt?**
Ein Gutschriftenverfahren ist nicht explizit vereinbart. Die AGB sehen vor, dass Rechnungen des Auftragnehmers nach Leistungserbringung zu zahlen sind (Ziffer 30: 'Die Rechnungen des Auftragnehmers sind 30 Tage nach Zugang [...] zur Zahlung fällig.').
*Score: 20%*
> Es gibt keine Regelung zum Gutschriftenverfahren. Die AGB regeln nur die Fälligkeit von Rechnungen, nicht jedoch die Erteilung von Gutschriften.
### Haftungsbeschränkungen & -ausschlüsse
❌ **Ist festgelegt, dass der Spediteur bei verschuldensabhängiger Haftung für Güterschäden statt Schadenersatz Wert- und Kostenersatz nach §§ 429, 430, 432 HGB leistet?**
Die AGB enthalten keine direkte Regelung zu Wert- und Kostenersatz nach §§ 429, 430, 432 HGB. Stattdessen wird die Haftung des Auftragnehmers auf 40 SZR pro Kilogramm Rohgewicht erhöht (Ziffer 16: 'Die vom Auftragnehmer zu leistende Entschädigung [...] wird auf 40 Sonderziehungsrechte [...] erhöht.').
*Score: 0%*
> Die AGB weichen von den gesetzlichen Bestimmungen ab und regeln die Haftung abweichend, ohne explizit Wert- und Kostenersatz nach HGB zu erwähnen.
✅ **Ist die Haftung für Güterschäden in der Obhut des Spediteurs auf 8,33 SZR pro Kilogramm Rohgewicht begrenzt?**
Die Haftung für Güterschäden ist auf 8,33 SZR pro Kilogramm Rohgewicht begrenzt (Ziffer 16: 'unsere Haftung als Absender [...] auf einen Betrag von 8,33 Sonderziehungsrechten für jedes Kilogramm des Rohgewichtes der Sendung beschränkt.').
*Score: 100%*
> Die AGB enthalten eine klare und wirksame Haftungsbegrenzung auf 8,33 SZR pro Kilogramm, was den gesetzlichen Vorgaben entspricht.
❌ **Ist ein Gesamthaftungshöchstbetrag von 1,25 Millionen Euro je Schadenfall bzw. 2,5 Millionen Euro je Schadenereignis vereinbart?**
Nein. Es ist keine Vereinbarung eines Gesamthaftungshöchstbetrags von 1,25 Millionen Euro je Schadenfall bzw. 2,5 Millionen Euro je Schadenereignis enthalten.
*Score: 0%*
> Die AGB enthalten keine explizite Regelung zu einem Gesamthaftungshöchstbetrag in den genannten Höhen. Die Haftung ist nur auf 40 SZR/kg begrenzt, was deutlich unter den geforderten Beträgen liegt.
❌ **Ist die Haftung für andere Schäden (außer Personenschäden und Drittsachschäden) auf das Dreifache des Güterschadenslimits plus maximal 125.000 Euro begrenzt?**
Nein. Die Haftung für andere Schäden (außer Personenschäden und Drittsachschäden) ist nicht auf das Dreifache des Güterschadenslimits plus maximal 125.000 Euro begrenzt.
*Score: 0%*
> Die AGB enthalten keine solche spezifische Haftungsbegrenzung. Die Haftung ist nur auf 40 SZR/kg begrenzt, ohne zusätzliche Obergrenzen für andere Schäden.
❌ **Ist die Haftung bei verfügter Lagerung auf 8,33 SZR/kg, maximal 35.000 Euro je Schadenfall und 70.000 Euro jährlich bei Inventurdifferenzen begrenzt?**
Nein. Die Haftung bei verfügter Lagerung ist nicht auf 8,33 SZR/kg, maximal 35.000 Euro je Schadenfall und 70.000 Euro jährlich bei Inventurdifferenzen begrenzt.
*Score: 0%*
> Die AGB enthalten keine Regelung zur Haftungsbegrenzung bei verfügter Lagerung. Die Haftung ist nur auf 40 SZR/kg begrenzt, ohne spezifische Lagerungsregelungen.
❌ **Sind die Haftungsausschlüsse für nautische Fehler, Feuer/Explosion an Bord und vor Reisebeginn bestehende Schiffsmängel gemäß HGB und CMNI enthalten?**
Nein. Die Haftungsausschlüsse für nautische Fehler, Feuer/Explosion an Bord und vor Reisebeginn bestehende Schiffsmängel gemäß HGB und CMNI sind nicht enthalten.
*Score: 0%*
> Die AGB enthalten keine solchen spezifischen Haftungsausschlüsse. Es handelt sich um Transport-AGB ohne Bezug zu Schiffs- oder Lagerungsrisiken.
❌ **Finden die Haftungsbeschränkungen und -ausschlüsse auch auf außervertragliche Ansprüche Anwendung?**
Nein, die Haftungsbeschränkungen und -ausschlüsse beziehen sich ausschließlich auf vertragliche Ansprüche (vgl. Ziffer 16 der AGB: 'Die Haftung des von uns beauftragten Auftragnehmers richtet sich ausschließlich nach den gesetzlichen Bestimmungen.').
*Score: 0%*
> Die AGB regeln explizit nur vertragliche Haftungsansprüche und schließen außervertragliche Ansprüche nicht ein. Dies entspricht nicht den Anforderungen an eine umfassende Haftungsbeschränkung.
### Sicherungsrechte & Aufrechnung
❌ **Ist die Aufrechnung oder Zurückbehaltung nur bei fälligen, unbestrittenen oder rechtskräftig festgestellten Gegenansprüchen zulässig?**
Nein, die AGB enthalten keine explizite Regelung zur Aufrechnung oder Zurückbehaltung. Lediglich in Ziffer 30 wird die Aufrechnung mit 'etwaigen uns zustehenden Gegenansprüchen' erwähnt, ohne jedoch die gesetzlichen Voraussetzungen (Fälligkeit, Unbestrittenheit oder rechtskräftige Feststellung) zu wiederholen oder einzuschränken.
*Score: 30%*
> Die AGB regeln die Aufrechnung nicht ausreichend präzise, um die gesetzlichen Anforderungen zu erfüllen. Eine klare Beschränkung auf fällige, unbestrittene oder rechtskräftig festgestellte Gegenansprüche fehlt.
⚠️ **Ist das gesetzliche Pfand- und Zurückbehaltungsrecht anerkannt und die Pfandverwertungsfrist auf eine Woche festgelegt?**
Nein, das gesetzliche Pfand- und Zurückbehaltungsrecht wird nicht explizit anerkannt, und eine Pfandverwertungsfrist von einer Woche ist nicht festgelegt. Die AGB enthalten hierzu keine Regelungen.
*Score: 0%*
> Die AGB schweigen vollständig zu Pfand- und Zurückbehaltungsrechten sowie deren Verwertung. Dies entspricht nicht den gesetzlichen Anforderungen.
⚠️ **Ist dem Auftraggeber das Recht eingeräumt, die Pfandverwertung durch Stellung eines gleichwertigen Sicherungsmittels zu untersagen?**
Nein, die AGB enthalten keine Regelung, die dem Auftraggeber das Recht einräumt, die Pfandverwertung durch Stellung eines gleichwertigen Sicherungsmittels zu untersagen.
*Score: 0%*
> Die AGB regeln weder das Pfandrecht noch die Möglichkeit, dessen Verwertung durch Sicherheitsleistung zu untersagen. Dies entspricht nicht den gesetzlichen Vorgaben.
### Versicherungsbesorgung & Nebenpflichten
⚠️ **Ist klargestellt, dass weitergehende Leistungs- und Informationspflichten nur bei ausdrücklicher Vereinbarung gelten?**
Nein. Das Dokument enthält keine explizite Regelung, dass weitergehende Leistungs- und Informationspflichten nur bei ausdrücklicher Vereinbarung gelten. Vielmehr werden zahlreiche Pflichten (z.B. Genehmigungen, Hygiene, Temperaturkontrolle) ohne Einschränkung auf Vereinbarungen auferlegt.
*Score: 0%*
> Die AGB enthalten keine Klausel, die weitergehende Pflichten an eine ausdrückliche Vereinbarung knüpft. Dies wäre jedoch erforderlich, um die Anforderung zu erfüllen.
⚠️ **Ist geregelt, dass der Spediteur die Güterversicherung auf Auftrag besorgt oder bei Interesse des Auftraggebers (z.B. Warenwertangabe) von sich aus eindeckt?**
Nein. Das Dokument regelt keine Pflicht des Auftragnehmers zur Besorgung einer Güterversicherung oder zur Eindeckung eines Versicherungsschutzes auf Initiative des Auftraggebers (z.B. bei Warenwertangabe).
*Score: 0%*
> Es fehlt eine klare Regelung zur Güterversicherung oder zur Eindeckung von Versicherungsschutz auf Weisung oder bei Interesse des Auftraggebers.
⚠️ **Enthält das Dokument die Pflicht zur Befolgung von Versicherungswisungen sowie zur unverzüglichen Mitteilung, falls kein Versicherungsschutz möglich ist?**
Nein. Das Dokument enthält keine explizite Pflicht zur Befolgung von Versicherungsweisungen oder zur unverzüglichen Mitteilung, falls kein Versicherungsschutz möglich ist. Lediglich in Ziffer 19 (Mindestlohn) wird auf die Einhaltung gesetzlicher Vorschriften verwiesen, jedoch nicht spezifisch zu Versicherungsweisungen.
*Score: 0%*
> Es fehlt eine konkrete Regelung zur Befolgung von Versicherungsweisungen oder zur Kommunikation von Versicherungslücken.
⚠️ **Ist eine ortsübliche Vergütung für die Abwicklung von Versicherungsfällen und Havereien auf Weisung vereinbart?**
Nein. Das Dokument enthält keine Regelung zu einer ortsüblichen Vergütung für die Abwicklung von Versicherungsfällen oder Havereien auf Weisung.
*Score: 0%*
> Es fehlt jegliche Vereinbarung oder Regelung zu Vergütungsfragen im Zusammenhang mit Versicherungsfällen oder Havereien.
### Haftungsausschlüsse & -begrenzungen
❌ **Ist bei Verletzung vertragswesentlicher Pflichten der Ersatzanspruch auf den vorhersehbaren, typischen Schaden begrenzt?**
Nein, die AGB enthalten keine explizite Begrenzung des Ersatzanspruchs auf vorhersehbare, typische Schäden bei Verletzung vertragswesentlicher Pflichten. Die Haftung wird zwar in Ziffer 16 auf 40 Sonderziehungsrechte (SZR) pro kg Rohgewicht erhöht (§ 431 HGB) und die Absenderhaftung auf 8,33 SZR pro kg beschränkt, jedoch wird keine Einschränkung auf vorhersehbare oder typische Schäden vorgenommen. Die Pönale-Regelung (150 € bei Verspätung/Nichteinhaltung) deutet eher auf eine pauschale Schadensersatzregelung hin, ohne Bezug zur Vorhersehbarkeit.
*Score: 30%*
> Die AGB regeln zwar Haftungshöchstgrenzen, aber keine Begrenzung auf vorhersehbare, typische Schäden. Dies wäre jedoch nach § 276 Abs. 3 BGB und § 435 HGB erforderlich, um eine wirksame Haftungsbegrenzung zu schaffen. Die Regelungen sind daher unvollständig und nicht ausreichend.
❌ **Ist die Haftung des Auftraggebers nach §§ 414, 455, 468 und 488 HGB auf 200.000 Euro je Schadenereignis begrenzt?**
Nein. Die Haftung des Auftraggebers als Absender ist in Ziffer 16 der AGB auf 8,33 Sonderziehungsrechte (SZR) je Kilogramm des Rohgewichts der Sendung begrenzt ("Abweichend von § 414 HGB ist unsere Haftung als Absender aus § 414 HGB auf einen Betrag von 8,33 Sonderziehungsrechten für jedes Kilogramm des Rohgewichtes der Sendung beschränkt"). Eine pauschale Begrenzung auf 200.000 Euro je Schadenereignis wird nicht genannt.
*Score: 0%*
> Die Haftungsbegrenzung des Auftraggebers ist zwar klar geregelt, aber nicht in der geforderten Höhe von 200.000 Euro. Stattdessen wird auf 8,33 SZR/kg abgestellt, was je nach Rohgewicht deutlich darunter oder darüber liegen kann. Eine pauschale Euro-Begrenzung fehlt vollständig.
### Haftpflichtversicherung & Nachweispflicht
✅ **Ist der Spediteur verpflichtet, auf Verlangen des Auftraggebers das Bestehen einer gültigen Haftpflichtversicherung durch eine Versicherungsbestätigung nachzuweisen?**
Ja, der Auftragnehmer ist verpflichtet, auf Verlangen das Bestehen einer gültigen Haftpflichtversicherung durch eine Versicherungsbestätigung nachzuweisen (Ziffer 17: 'Auf Verlangen hat uns der Auftragnehmer das Bestehen dieser Versicherungen durch eine Bestätigung der Versicherungsgesellschaft [...] nachzuweisen').
*Score: 100%*
> Die Anforderung ist klar und explizit in Ziffer 17 geregelt. Der Nachweis ist auf Verlangen zu erbringen, was den gesetzlichen Anforderungen entspricht.
❌ **Kann der Auftraggeber den Vertrag außerordentlich kündigen, wenn der Spediteur den Versicherungsnachweis innerhalb einer angemessenen Frist nicht erbringt?**
Nein, die AGB enthalten keine ausdrückliche Regelung zur außerordentlichen Kündigung bei Nichtvorlage des Versicherungsnachweises. Ziffer 33 regelt zwar eine Kürzung der Frachtvergütung und Kündigungsmöglichkeiten bei Vertragsverletzungen, aber nicht spezifisch für Versicherungsnachweise.
*Score: 0%*
> Es fehlt eine klare vertragliche Regelung zur Kündigung bei Nichtvorlage des Versicherungsnachweises. Die allgemeine Kündigungsregelung in Ziffer 33 ist nicht spezifisch genug.
❌ **Darf der Spediteur sich nur bei ausreichendem Versicherungsschutz bei Auftragserteilung auf die Haftungsbestimmungen der ADSp berufen?**
Nein, die AGB enthalten keine direkte Regelung, dass sich der Spediteur nur bei ausreichendem Versicherungsschutz auf die Haftungsbestimmungen der ADSp berufen darf. Ziffer 16 regelt zwar die Haftung, aber nicht den Zusammenhang mit der ADSp oder Versicherungsschutz bei Auftragserteilung.
*Score: 0%*
> Die AGB verweisen nicht auf die ADSp (Ziffer 16 regelt die Haftung eigenständig) und enthalten keine Bedingung für die Berufung auf ADSp-Haftungsbestimmungen in Abhängigkeit vom Versicherungsschutz.
### Rechtswahl, Erfüllungsort & Gerichtsstand
✅ **Gilt für die Rechtsbeziehung zwischen Spediteur und Auftraggeber deutsches Recht?**
Ja, für die Rechtsbeziehung gilt deutsches Recht (Ziffer 36: 'Zwischen dem Auftragnehmer und uns gilt deutsches Recht').
*Score: 100%*
> Die Rechtswahl ist explizit in Ziffer 36 geregelt und entspricht den gesetzlichen Anforderungen.
❌ **Ist der Erfüllungsort für alle Beteiligten der Ort derjenigen Niederlassung des Spediteurs, an die der Auftrag gerichtet ist?**
Nein. Der Erfüllungsort wird im Dokument nicht explizit geregelt. Es wird lediglich der Gerichtsstand in Ziffer 36 als "Schortens" vereinbart, aber keine Aussage zum Erfüllungsort getroffen. ("Der Gerichtsstand Schortens ist als vereinbart.")
*Score: 0%*
> Die AGB enthalten keine Regelung zum Erfüllungsort. Dies ist ein zentrales Manko, da der Erfüllungsort für die Bestimmung des anwendbaren Rechts und die Zuständigkeit von Bedeutung ist. Eine klare Regelung fehlt vollständig.
### Compliance, Geheimhaltung & Arbeitsrecht
✅ **Ist der Spediteur verpflichtet, Mindestlohn- und Arbeitsplatzvorschriften einzuhalten und den Auftraggeber von Ansprüchen wegen Nichtzahlung des Mindestlohns freizustellen?**
Ja, der Auftragnehmer ist verpflichtet, die Vorschriften des Mindestlohngesetzes einzuhalten (§19). Bei Verstößen hat er den Auftraggeber von Ansprüchen Dritter freizustellen und entstandene Schäden zu ersetzen ('hat uns bei Verstößen von etwaigen Ansprüchen Dritter freizustellen und uns alle hieraus entstehenden Schäden zu ersetzen').
*Score: 100%*
> Die AGB enthalten eine klare Verpflichtung zur Einhaltung des Mindestlohns und eine Freistellungspflicht bei Verstößen. Dies entspricht den gesetzlichen Anforderungen.
✅ **Ist sichergestellt, dass der Spediteur GüKG-Erlaubnis/Lizenz besitzt, Fahrerqualifikation nach §7b GüKG erfüllt und gesetzlich mitzuführende Dokumente auf Anforderung vorlegt?**
Ja, der Auftragnehmer muss über die erforderlichen Genehmigungen nach §§ 3, 6 GüKG verfügen und diese auf Verlangen nachweisen (§2). Die Fahrer müssen die jeweilige Erlaubnisausfertigung für das eingesetzte Fahrzeug mitführen ('verfügt und die Fahrer die jeweilig für den Transport erforderliche Erlaubnisausfertigung für das jeweilig eingesetzte Fahrzeug mit sich führen').
*Score: 100%*
> Die AGB fordern explizit die Einhaltung der GüKG-Vorschriften und den Nachweis der Genehmigungen. Dies entspricht den gesetzlichen Anforderungen.
❌ **Ist die Organisation der Arbeitszeiten, Lenk- und Ruhezeiten sowie ein generelles Alkohol- und Drogenverbot beim Führen des Fahrzeugs geregelt?**
Nein, die AGB enthalten keine explizite Regelung zu Lenk- und Ruhezeiten oder ein generelles Alkohol- und Drogenverbot. Lediglich die Einhaltung aller gesetzlichen Vorschriften wird pauschal gefordert (§19), ohne konkrete Vorgaben zu nennen.
*Score: 0%*
> Die AGB verweisen zwar auf die Einhaltung gesetzlicher Vorschriften, enthalten aber keine konkreten Regelungen zu Lenk- und Ruhezeiten oder Alkohol-/Drogenverboten. Dies ist ein erhebliches Compliance-Defizit.
⚠️ **Werden die Einhaltung von UNGC/ILO-Grundsätzen, das Verbot von Kinder- und Zwangsarbeit, Diskriminierungsverbote sowie Umwelt- und Antikorruptionsstandards verbindlich festgelegt?**
Nein, die AGB enthalten keine verbindlichen Festlegungen zu UNGC/ILO-Grundsätzen, Kinder- oder Zwangsarbeit, Diskriminierungsverboten oder Umwelt-/Antikorruptionsstandards. Es wird lediglich auf die Einhaltung aller gesetzlichen Vorschriften verwiesen (§19), ohne konkrete Standards zu nennen.
*Score: 0%*
> Die AGB enthalten keine spezifischen Regelungen zu ethischen oder ökologischen Standards. Lediglich die Einhaltung gesetzlicher Vorschriften wird pauschal gefordert, was für eine verbindliche Festlegung nicht ausreicht.
### Manuelle Abfragen
✅ **Sind konditionale Zahlungen, wie Pönalen vorhanden?
Liste sie alle auf!**
Ja, es ist eine Pönale in Höhe von 150,-- Euro bei verspäteter Anlieferung und/oder Nichteinhaltung der Vorgaben aus dem Transportauftrag vorgesehen ("Bei verspäteter Anlieferung und/oder Nichteinhaltung der Vorgaben aus dem Transportauftrag, wird eine Pönale von 150,-- Euro berechnet zzgl. der daraus entstandenen Kosten oder Schäden.").
*Score: 100%*
> Die Pönale ist klar definiert und umfasst sowohl Verspätungen als auch Nichteinhaltungen von Transportvorgaben. Die Höhe ist konkret angegeben.
---