- Added new fields to CPuls entity including status, syncStatus, kiAnalyse, and others. - Implemented localization for CPuls in German (de_DE) and English (en_US). - Introduced new API actions for team activation and completion of CPuls. - Created hooks to update team statistics and manage document counts. - Added new entity definitions and metadata for CPulsTeamZuordnung and Team. - Implemented validation logic in formulas to prevent completion of unclean Puls. - Updated layouts for detail and list views of CPuls. - Enhanced user entity with absence tracking fields. - Added scopes for CPuls and CPulsTeamZuordnung.
2512 lines
66 KiB
Markdown
2512 lines
66 KiB
Markdown
# Pulssplan: Puls-System (Posteingang mit KI-Analyse)
|
|
|
|
**Version:** 2.0
|
|
**Datum:** 11. Februar 2026
|
|
**Status:** Erweiterte Spezifikation mit First-Read-Closes & Advanced Features - Bereit für Implementierung
|
|
|
|
---
|
|
|
|
## 📋 Executive Summary
|
|
|
|
### Ziel
|
|
Implementierung eines intelligenten Posteingangs-Systems für Dokumente zu Vorgängen (Räumungsklagen, Mietinkasso, Kündigungen). Dokumente werden automatisch zu "Entwicklungen" gruppiert, durch KI analysiert und relevanten Teams zur Review vorgelegt.
|
|
|
|
### Kernprinzipien
|
|
1. **EspoCRM = Data Layer** - Speichert Entities, stellt UI bereit, validiert Daten
|
|
2. **Middleware = Business Logic** - KI-Analyse, Team-Zuweisung, Abwesenheitsvertretung
|
|
3. **Clean Separation** - Keine komplexen Hooks/Workflows in EspoCRM
|
|
4. **Team-basiert** - Dynamische Zuordnung zu Teams statt fixer Workflows
|
|
5. **First-Read-Closes** - Sobald ein Team eine Puls abschließt, wird der Block finalisiert. Alle nachfolgenden Dokumente bilden automatisch einen neuen Block. Dadurch sehen alle Teams identische Dokumenten-Gruppierungen und es gibt keine asynchronen Inkonsistenzen.
|
|
|
|
### Architektur-Übersicht
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ MIDDLEWARE │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ Dokument- │ │ KI-Analyse │ │ Abwesenheits-│ │
|
|
│ │ Polling │→ │ & Team- │→ │ Management │ │
|
|
│ │ │ │ Entscheidung │ │ │ │
|
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
│ ↕ API ↕ API ↕ API │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
↕
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ ESPOCRM │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
│ │ CPuls │←→│ CPuls │←→│ CDokumente │ │
|
|
│ │ │ │ TeamZuordnung│ │ │ │
|
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
│ ↑ ↑ ↑ │
|
|
│ └──────────────────┴──────────────────┘ │
|
|
│ Parent: Räumungsklage / Mietinkasso │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Phase 1: Entities & Datenmodell (MVP)
|
|
|
|
### 1.1 Entity: CPuls
|
|
|
|
**Zweck:** Gruppierung von Dokumenten mit KI-Analyse und Status-Tracking
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"name": {
|
|
"type": "varchar",
|
|
"required": true,
|
|
"maxLength": 255,
|
|
"trim": true,
|
|
"isCustom": true
|
|
},
|
|
"status": {
|
|
"type": "enum",
|
|
"options": [
|
|
"Neu",
|
|
"In Verarbeitung",
|
|
"Bereit",
|
|
"In Review",
|
|
"Teilweise abgeschlossen",
|
|
"Abgeschlossen"
|
|
],
|
|
"default": "Neu",
|
|
"required": true,
|
|
"isCustom": true,
|
|
"style": {
|
|
"Neu": "default",
|
|
"In Verarbeitung": "primary",
|
|
"Bereit": "success",
|
|
"In Review": "warning",
|
|
"Teilweise abgeschlossen": "info",
|
|
"Abgeschlossen": "success"
|
|
}
|
|
},
|
|
"syncStatus": {
|
|
"type": "enum",
|
|
"options": ["clean", "unclean"],
|
|
"default": "unclean",
|
|
"required": true,
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"kiAnalyse": {
|
|
"type": "text",
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"zusammenfassung": {
|
|
"type": "varchar",
|
|
"maxLength": 500,
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"anzahlDokumente": {
|
|
"type": "int",
|
|
"readOnly": true,
|
|
"notStorable": false,
|
|
"isCustom": true
|
|
},
|
|
"anzahlTeamsAktiv": {
|
|
"type": "int",
|
|
"readOnly": true,
|
|
"notStorable": false,
|
|
"isCustom": true
|
|
},
|
|
"anzahlTeamsAbgeschlossen": {
|
|
"type": "int",
|
|
"readOnly": true,
|
|
"notStorable": false,
|
|
"isCustom": true
|
|
},
|
|
"finalisiert": {
|
|
"type": "bool",
|
|
"default": false,
|
|
"readOnly": true,
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"finalisierungsGrund": {
|
|
"type": "enum",
|
|
"options": [
|
|
"Erstes Team",
|
|
"Manuell",
|
|
"Automatisch"
|
|
],
|
|
"readOnly": true,
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"finalisiertAm": {
|
|
"type": "datetime",
|
|
"readOnly": true,
|
|
"isCustom": true
|
|
},
|
|
"finalisiertVon": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"readOnly": true,
|
|
"isCustom": true
|
|
},
|
|
"createdAt": {
|
|
"type": "datetime",
|
|
"readOnly": true
|
|
},
|
|
"modifiedAt": {
|
|
"type": "datetime",
|
|
"readOnly": true
|
|
},
|
|
"createdBy": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"readOnly": true
|
|
},
|
|
"modifiedBy": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"readOnly": true
|
|
},
|
|
"assignedUser": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"isCustom": true
|
|
},
|
|
"teams": {
|
|
"type": "linkMultiple",
|
|
"isCustom": true
|
|
}
|
|
},
|
|
|
|
"links": {
|
|
"parent": {
|
|
"type": "belongsToParent",
|
|
"entityList": [
|
|
"CVmhRumungsklage",
|
|
"CMietinkasso",
|
|
"CKuendigung"
|
|
]
|
|
},
|
|
"dokumente": {
|
|
"type": "hasMany",
|
|
"entity": "CDokumente",
|
|
"foreign": "puls"
|
|
},
|
|
"teamZuordnungen": {
|
|
"type": "hasMany",
|
|
"entity": "CPulsTeamZuordnung",
|
|
"foreign": "puls"
|
|
},
|
|
"createdBy": {
|
|
"type": "belongsTo",
|
|
"entity": "User"
|
|
},
|
|
"modifiedBy": {
|
|
"type": "belongsTo",
|
|
"entity": "User"
|
|
},
|
|
"assignedUser": {
|
|
"type": "belongsTo",
|
|
"entity": "User"
|
|
},
|
|
"finalisiertVon": {
|
|
"type": "belongsTo",
|
|
"entity": "User"
|
|
},
|
|
"teams": {
|
|
"type": "hasMany",
|
|
"entity": "Team",
|
|
"relationName": "EntityTeam",
|
|
"layoutRelationshipsDisabled": true
|
|
}
|
|
},
|
|
|
|
"collection": {
|
|
"orderBy": "createdAt",
|
|
"order": "desc",
|
|
"textFilterFields": ["name", "zusammenfassung"]
|
|
},
|
|
|
|
"indexes": {
|
|
"parent": {
|
|
"columns": ["parentType", "parentId"]
|
|
},
|
|
"status": {
|
|
"columns": ["status"]
|
|
},
|
|
"syncStatus": {
|
|
"columns": ["syncStatus"]
|
|
},
|
|
"finalisiert": {
|
|
"columns": ["finalisiert"]
|
|
},
|
|
"createdAt": {
|
|
"columns": ["createdAt"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 1.2 Entity: CPulsTeamZuordnung
|
|
|
|
**Zweck:** Junction Table für dynamische Team-Zuordnung mit Abschluss-Tracking
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/CPulsTeamZuordnung.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"name": {
|
|
"type": "varchar",
|
|
"notStorable": true,
|
|
"select": {
|
|
"select": "CONCAT:(team.name, ' - ', puls.name)"
|
|
},
|
|
"orderBy": {
|
|
"order": [
|
|
["team.name", "{direction}"]
|
|
]
|
|
}
|
|
},
|
|
"puls": {
|
|
"type": "link",
|
|
"entity": "CPuls",
|
|
"required": true,
|
|
"isCustom": true
|
|
},
|
|
"team": {
|
|
"type": "link",
|
|
"entity": "Team",
|
|
"required": true,
|
|
"isCustom": true
|
|
},
|
|
"aktiv": {
|
|
"type": "bool",
|
|
"default": true,
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"abgeschlossen": {
|
|
"type": "bool",
|
|
"default": false,
|
|
"isCustom": true
|
|
},
|
|
"abgeschlossenAm": {
|
|
"type": "datetime",
|
|
"readOnly": true,
|
|
"isCustom": true
|
|
},
|
|
"abgeschlossenVon": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"readOnly": true,
|
|
"isCustom": true
|
|
},
|
|
"prioritaet": {
|
|
"type": "enum",
|
|
"options": ["Niedrig", "Normal", "Hoch"],
|
|
"default": "Normal",
|
|
"isCustom": true,
|
|
"style": {
|
|
"Niedrig": "default",
|
|
"Normal": "primary",
|
|
"Hoch": "danger"
|
|
}
|
|
},
|
|
"createdAt": {
|
|
"type": "datetime",
|
|
"readOnly": true
|
|
},
|
|
"modifiedAt": {
|
|
"type": "datetime",
|
|
"readOnly": true
|
|
}
|
|
},
|
|
|
|
"links": {
|
|
"puls": {
|
|
"type": "belongsTo",
|
|
"entity": "CPuls",
|
|
"foreign": "teamZuordnungen"
|
|
},
|
|
"team": {
|
|
"type": "belongsTo",
|
|
"entity": "Team"
|
|
},
|
|
"abgeschlossenVon": {
|
|
"type": "belongsTo",
|
|
"entity": "User"
|
|
}
|
|
},
|
|
|
|
"collection": {
|
|
"orderBy": "createdAt",
|
|
"order": "desc"
|
|
},
|
|
|
|
"indexes": {
|
|
"pulsTeam": {
|
|
"columns": ["pulsId", "teamId"],
|
|
"unique": true
|
|
},
|
|
"aktiv": {
|
|
"columns": ["aktiv"]
|
|
},
|
|
"abgeschlossen": {
|
|
"columns": ["abgeschlossen"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 1.3 Team-Entity erweitern
|
|
|
|
**Zweck:** Kategorisierung für Filter-Logik (Anwalt vs. Team-Teams)
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/Team.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"teamKategorie": {
|
|
"type": "enum",
|
|
"options": [
|
|
"Anwalt",
|
|
"Mandatsbetreuung",
|
|
"Zwangsvollstreckung",
|
|
"Sonstiges"
|
|
],
|
|
"default": "Sonstiges",
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Post-Setup-Aufgabe:** Bestehende Teams kategorisieren
|
|
- Team "Anwalt" → teamKategorie = "Anwalt"
|
|
- Team "Mandatsbetreuung" → teamKategorie = "Mandatsbetreuung"
|
|
- Team "Zwangsvollstreckung" → teamKategorie = "Zwangsvollstreckung"
|
|
|
|
---
|
|
|
|
### 1.4 User-Entity erweitern
|
|
|
|
**Zweck:** Abwesenheits-Management für automatische Umverteilung
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/User.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"abwesend": {
|
|
"type": "bool",
|
|
"default": false,
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"abwesendBis": {
|
|
"type": "date",
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
},
|
|
"vertretung": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"isCustom": true,
|
|
"tooltip": true
|
|
}
|
|
},
|
|
|
|
"links": {
|
|
"vertretung": {
|
|
"type": "belongsTo",
|
|
"entity": "User",
|
|
"isCustom": true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 1.5 CDokumente erweitern
|
|
|
|
**Zweck:** Verknüpfung zu Pulsen
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/CDokumente.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"puls": {
|
|
"type": "link",
|
|
"entity": "CPuls",
|
|
"isCustom": true
|
|
}
|
|
},
|
|
|
|
"links": {
|
|
"puls": {
|
|
"type": "belongsTo",
|
|
"entity": "CPuls",
|
|
"foreign": "dokumente",
|
|
"isCustom": true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 1.6 Scopes definieren
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/scopes/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"entity": true,
|
|
"tab": true,
|
|
"acl": "recordAllTeamOwnNo",
|
|
"aclPortal": false,
|
|
"customizable": true,
|
|
"stream": true,
|
|
"disabled": false,
|
|
"type": "Base",
|
|
"module": "Custom",
|
|
"object": true,
|
|
"isCustom": true,
|
|
"importable": false,
|
|
"notifications": true,
|
|
"calendar": false
|
|
}
|
|
```
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/scopes/CPulsTeamZuordnung.json`
|
|
|
|
```json
|
|
{
|
|
"entity": true,
|
|
"tab": false,
|
|
"acl": "recordAllTeamNo",
|
|
"aclPortal": false,
|
|
"customizable": true,
|
|
"stream": false,
|
|
"disabled": false,
|
|
"type": "Base",
|
|
"module": "Custom",
|
|
"object": true,
|
|
"isCustom": true
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 1.7 Internationalisierung (i18n)
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"labels": {
|
|
"Create CPuls": "Puls erstellen",
|
|
"CPuls": "Entwicklung",
|
|
"cEntwicklungs": "Entwicklungen"
|
|
},
|
|
"fields": {
|
|
"name": "Bezeichnung",
|
|
"status": "Status",
|
|
"syncStatus": "Synchronisations-Status",
|
|
"kiAnalyse": "KI-Analyse",
|
|
"zusammenfassung": "Zusammenfassung",
|
|
"anzahlDokumente": "Anzahl Dokumente",
|
|
"anzahlTeamsAktiv": "Teams (aktiv)",
|
|
"anzahlTeamsAbgeschlossen": "Teams (abgeschlossen)",
|
|
"finalisiert": "Finalisiert",
|
|
"finalisierungsGrund": "Finalisierungsgrund",
|
|
"finalisiertAm": "Finalisiert am",
|
|
"finalisiertVon": "Finalisiert von",
|
|
"parent": "Vorgang",
|
|
"dokumente": "Dokumente",
|
|
"teamZuordnungen": "Team-Zuordnungen"
|
|
},
|
|
"links": {
|
|
"parent": "Vorgang",
|
|
"dokumente": "Dokumente",
|
|
"teamZuordnungen": "Team-Zuordnungen"
|
|
},
|
|
"tooltips": {
|
|
"syncStatus": "clean = KI-Analyse aktuell | unclean = Neue Dokumente, Analyse ausstehend",
|
|
"kiAnalyse": "Automatisch generierte Zusammenfassung durch KI-Middleware",
|
|
"zusammenfassung": "Kurze Zusammenfassung für Listen-Ansicht",
|
|
"finalisiert": "Block wurde geschlossen - neue Dokumente erzeugen automatisch einen neuen Block (First-Read-Closes Prinzip)",
|
|
"finalisierungsGrund": "Grund der Finalisierung: Erstes Team = Team hat abgeschlossen | Manuell = Admin-Aktion | Automatisch = System-Regel"
|
|
},
|
|
"options": {
|
|
"status": {
|
|
"Neu": "Neu",
|
|
"In Verarbeitung": "In Verarbeitung",
|
|
"Bereit": "Bereit",
|
|
"In Review": "In Review",
|
|
"Teilweise abgeschlossen": "Teilweise abgeschlossen",
|
|
"Abgeschlossen": "Abgeschlossen"
|
|
},
|
|
"syncStatus": {
|
|
"clean": "Aktuell",
|
|
"unclean": "Ausstehend"
|
|
},
|
|
"finalisierungsGrund": {
|
|
"Erstes Team": "Erstes Team",
|
|
"Manuell": "Manuell",
|
|
"Automatisch": "Automatisch"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/i18n/en_US/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"labels": {
|
|
"Create CPuls": "Create Development",
|
|
"CPuls": "Development",
|
|
"cEntwicklungs": "Developments"
|
|
},
|
|
"fields": {
|
|
"name": "Name",
|
|
"status": "Status",
|
|
"syncStatus": "Sync Status",
|
|
"kiAnalyse": "AI Analysis",
|
|
"zusammenfassung": "Summary",
|
|
"anzahlDokumente": "Number of Documents",
|
|
"anzahlTeamsAktiv": "Teams (active)",
|
|
"anzahlTeamsAbgeschlossen": "Teams (completed)",
|
|
"finalisiert": "Finalized",
|
|
"finalisierungsGrund": "Finalization Reason",
|
|
"finalisiertAm": "Finalized At",
|
|
"finalisiertVon": "Finalized By",
|
|
"parent": "Parent Record",
|
|
"dokumente": "Documents",
|
|
"teamZuordnungen": "Team Assignments"
|
|
},
|
|
"links": {
|
|
"parent": "Parent Record",
|
|
"dokumente": "Documents",
|
|
"teamZuordnungen": "Team Assignments"
|
|
},
|
|
"tooltips": {
|
|
"syncStatus": "clean = AI analysis up-to-date | unclean = New documents, analysis pending",
|
|
"kiAnalyse": "Automatically generated summary by AI middleware",
|
|
"zusammenfassung": "Short summary for list views",
|
|
"finalisiert": "Block has been closed - new documents will automatically create a new block (First-Read-Closes principle)",
|
|
"finalisierungsGrund": "Reason for finalization: First Team = Team completed | Manual = Admin action | Automatic = System rule"
|
|
},
|
|
"options": {
|
|
"status": {
|
|
"Neu": "New",
|
|
"In Verarbeitung": "Processing",
|
|
"Bereit": "Ready",
|
|
"In Review": "In Review",
|
|
"Teilweise abgeschlossen": "Partially Completed",
|
|
"Abgeschlossen": "Completed"
|
|
},
|
|
"syncStatus": {
|
|
"clean": "Up-to-date",
|
|
"unclean": "Pending"
|
|
},
|
|
"finalisierungsGrund": {
|
|
"Erstes Team": "First Team",
|
|
"Manuell": "Manual",
|
|
"Automatisch": "Automatic"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Analog für CPulsTeamZuordnung, Team.teamKategorie, User.abwesend**
|
|
|
|
---
|
|
|
|
## 🔧 Phase 2: Validierung & Business Rules
|
|
|
|
### 2.1 Formula-Script: Abschluss nur bei clean
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/formula/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"beforeSaveApiScript": "// Verhindere Abschluss bei unclean Status\nif (\n (status == 'Abgeschlossen' || entity\\isAttributeChanged('status'))\n && syncStatus == 'unclean'\n) {\n recordService\\throwBadRequest('Puls kann nicht abgeschlossen werden: Neue Dokumente vorhanden (Status: unclean). Bitte warten Sie auf die KI-Analyse.');\n}\n\n// Verhindere Änderungen an finalisierter Puls\nif (\n finalisiert == true\n && entity\\isAttributeChanged('finalisiert') == false\n && (entity\\isAttributeChanged('status') || entity\\isAttributeChanged('syncStatus'))\n) {\n recordService\\throwBadRequest('Puls ist finalisiert. Neue Dokumente erzeugen automatisch einen neuen Block.');\n}"
|
|
}
|
|
```
|
|
|
|
**Test-Szenario:**
|
|
1. Puls mit syncStatus = "unclean"
|
|
2. User versucht manuell status = "Abgeschlossen" zu setzen
|
|
3. Erwartung: Error-Message, Speichern verhindert
|
|
|
|
---
|
|
|
|
### 2.2 Hook: Berechnete Felder aktualisieren
|
|
|
|
**Datei:** `custom/Espo/Custom/Hooks/CPuls/UpdateTeamStats.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Hooks\CPuls;
|
|
|
|
use Espo\ORM\Entity;
|
|
use Espo\Core\Hook\Hook\BeforeSave;
|
|
|
|
class UpdateTeamStats implements BeforeSave
|
|
{
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager
|
|
) {}
|
|
|
|
public function beforeSave(Entity $entity, array $options): void
|
|
{
|
|
// Zähle Dokumente
|
|
if ($entity->isNew() || $entity->isAttributeChanged('id')) {
|
|
$dokumenteCount = $this->entityManager
|
|
->getRDBRepository('CDokumente')
|
|
->where(['pulsId' => $entity->getId()])
|
|
->count();
|
|
|
|
$entity->set('anzahlDokumente', $dokumenteCount);
|
|
}
|
|
|
|
// Zähle Team-Zuordnungen
|
|
$zuordnungen = $this->entityManager
|
|
->getRDBRepository('CPulsTeamZuordnung')
|
|
->where(['pulsId' => $entity->getId()])
|
|
->find();
|
|
|
|
$aktiv = 0;
|
|
$abgeschlossen = 0;
|
|
|
|
foreach ($zuordnungen as $z) {
|
|
if ($z->get('aktiv')) {
|
|
$aktiv++;
|
|
if ($z->get('abgeschlossen')) {
|
|
$abgeschlossen++;
|
|
}
|
|
}
|
|
}
|
|
|
|
$entity->set('anzahlTeamsAktiv', $aktiv);
|
|
$entity->set('anzahlTeamsAbgeschlossen', $abgeschlossen);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Phase 3: Layouts & UI
|
|
|
|
### 3.1 Detail-Layout
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/layouts/CPuls/detail.json`
|
|
|
|
```json
|
|
[
|
|
{
|
|
"label": "Übersicht",
|
|
"rows": [
|
|
[
|
|
{"name": "name"},
|
|
{"name": "status"}
|
|
],
|
|
[
|
|
{"name": "syncStatus"},
|
|
{"name": "parent"}
|
|
],
|
|
[
|
|
{"name": "anzahlDokumente"},
|
|
{"name": "anzahlTeamsAktiv"}
|
|
],
|
|
[
|
|
{"name": "finalisiert"},
|
|
{"name": "finalisierungsGrund"}
|
|
],
|
|
[
|
|
{"name": "zusammenfassung", "span": 2}
|
|
]
|
|
]
|
|
},
|
|
{
|
|
"label": "KI-Analyse",
|
|
"rows": [
|
|
[
|
|
{"name": "kiAnalyse", "span": 2}
|
|
]
|
|
]
|
|
},
|
|
{
|
|
"label": "System",
|
|
"rows": [
|
|
[
|
|
{"name": "createdAt"},
|
|
{"name": "modifiedAt"}
|
|
],
|
|
[
|
|
{"name": "createdBy"},
|
|
{"name": "modifiedBy"}
|
|
]
|
|
]
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
### 3.2 List-Layout
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/layouts/CPuls/list.json`
|
|
|
|
```json
|
|
[
|
|
{"name": "name", "width": 30},
|
|
{"name": "status", "width": 15},
|
|
{"name": "syncStatus", "width": 10},
|
|
{"name": "parent", "width": 20},
|
|
{"name": "anzahlDokumente", "width": 10},
|
|
{"name": "createdAt", "width": 15}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
### 3.3 Bottom-Panels
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/layouts/CPuls/bottomPanelsDetail.json`
|
|
|
|
```json
|
|
{
|
|
"teamZuordnungen": {
|
|
"index": 0,
|
|
"sticked": true,
|
|
"style": "info",
|
|
"label": "Team-Zuordnungen"
|
|
},
|
|
"dokumente": {
|
|
"index": 1,
|
|
"sticked": false,
|
|
"label": "Dokumente"
|
|
},
|
|
"stream": {
|
|
"index": 2,
|
|
"sticked": false
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 3.4 ClientDefs
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/clientDefs/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"controller": "controllers/record",
|
|
"iconClass": "fas fa-inbox",
|
|
"color": "#3498db",
|
|
"filterList": [
|
|
"meineOffenen",
|
|
{
|
|
"name": "bereit"
|
|
},
|
|
{
|
|
"name": "inReview"
|
|
}
|
|
],
|
|
"boolFilterList": [
|
|
"onlyMy"
|
|
],
|
|
"defaultFilterPreset": "meineOffenen"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔌 Phase 4: Custom API Endpoints
|
|
|
|
### 4.1 API: Team-Aktivierung
|
|
|
|
**Datei:** `custom/Espo/Custom/Api/CPuls/AktiviereTeams.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Api\CPuls;
|
|
|
|
use Espo\Core\Api\Action;
|
|
use Espo\Core\Api\Request;
|
|
use Espo\Core\Api\Response;
|
|
use Espo\Core\Exceptions\BadRequest;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
|
|
class AktiviereTeams implements Action
|
|
{
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager,
|
|
private \Espo\Core\Utils\Log $log
|
|
) {}
|
|
|
|
public function process(Request $request): Response
|
|
{
|
|
$id = $request->getRouteParam('id');
|
|
|
|
if (!$id) {
|
|
throw new BadRequest('ID fehlt');
|
|
}
|
|
|
|
$puls = $this->entityManager->getEntity('CPuls', $id);
|
|
|
|
if (!$puls) {
|
|
throw new NotFound('Puls nicht gefunden');
|
|
}
|
|
|
|
$data = $request->getParsedBody();
|
|
|
|
// 1. Update Puls
|
|
$puls->set([
|
|
'kiAnalyse' => $data->kiAnalyse ?? null,
|
|
'zusammenfassung' => $data->zusammenfassung ?? null,
|
|
'status' => $data->status ?? 'Bereit',
|
|
'syncStatus' => $data->syncStatus ?? 'clean'
|
|
]);
|
|
|
|
$this->entityManager->saveEntity($puls);
|
|
|
|
// 2. Lösche alte Zuordnungen (soft delete - setze inaktiv)
|
|
$this->entityManager
|
|
->getQueryBuilder()
|
|
->update()
|
|
->in('CPulsTeamZuordnung')
|
|
->set(['aktiv' => false])
|
|
->where(['pulsId' => $id])
|
|
->execute();
|
|
|
|
// 3. Erstelle neue Zuordnungen
|
|
if (isset($data->teams) && is_array($data->teams)) {
|
|
foreach ($data->teams as $teamData) {
|
|
$teamId = $teamData->teamId ?? null;
|
|
|
|
if (!$teamId) {
|
|
$this->log->warning("Team-ID fehlt in teams-Array");
|
|
continue;
|
|
}
|
|
|
|
// Prüfe ob bereits existiert
|
|
$existing = $this->entityManager
|
|
->getRDBRepository('CPulsTeamZuordnung')
|
|
->where([
|
|
'pulsId' => $id,
|
|
'teamId' => $teamId
|
|
])
|
|
->findOne();
|
|
|
|
if ($existing) {
|
|
// Reaktiviere
|
|
$existing->set([
|
|
'aktiv' => true,
|
|
'abgeschlossen' => false,
|
|
'prioritaet' => $teamData->prioritaet ?? 'Normal'
|
|
]);
|
|
$this->entityManager->saveEntity($existing);
|
|
} else {
|
|
// Erstelle neu
|
|
$zuordnung = $this->entityManager->createEntity('CPulsTeamZuordnung', [
|
|
'pulsId' => $id,
|
|
'teamId' => $teamId,
|
|
'aktiv' => true,
|
|
'abgeschlossen' => false,
|
|
'prioritaet' => $teamData->prioritaet ?? 'Normal'
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->log->info("Teams aktiviert für Puls {$id}");
|
|
|
|
return Response::json([
|
|
'success' => true,
|
|
'pulsId' => $id
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Route registrieren:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/app/api.json`
|
|
|
|
```json
|
|
{
|
|
"routes": [
|
|
{
|
|
"route": "/CPuls/:id/aktiviere-teams",
|
|
"method": "put",
|
|
"actionClassName": "Espo\\Custom\\Api\\CPuls\\AktiviereTeams"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4.2 API: Abschluss für Team
|
|
|
|
**Datei:** `custom/Espo/Custom/Api/CPuls/AbschliessenFuerTeam.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Api\CPuls;
|
|
|
|
use Espo\Core\Api\Action;
|
|
use Espo\Core\Api\Request;
|
|
use Espo\Core\Api\Response;
|
|
use Espo\Core\Exceptions\BadRequest;
|
|
use Espo\Core\Exceptions\Forbidden;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
|
|
class AbschliessenFuerTeam implements Action
|
|
{
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager,
|
|
private \Espo\Core\Acl\Table $acl,
|
|
private \Espo\Entities\User $user,
|
|
private \Espo\Core\Utils\Log $log
|
|
) {}
|
|
|
|
public function process(Request $request): Response
|
|
{
|
|
$pulsId = $request->getRouteParam('id');
|
|
$data = $request->getParsedBody();
|
|
$teamId = $data->teamId ?? null;
|
|
|
|
if (!$pulsId || !$teamId) {
|
|
throw new BadRequest('pulsId oder teamId fehlt');
|
|
}
|
|
|
|
// 1. Validierung: Ist User in diesem Team?
|
|
$userTeams = $this->user->getLinkMultipleIdList('teams');
|
|
|
|
if (!in_array($teamId, $userTeams)) {
|
|
throw new Forbidden('User nicht in angegebenem Team');
|
|
}
|
|
|
|
// 2. Lade Puls
|
|
$puls = $this->entityManager->getEntity('CPuls', $pulsId);
|
|
|
|
if (!$puls) {
|
|
throw new NotFound('Puls nicht gefunden');
|
|
}
|
|
|
|
// 3. Validierung: syncStatus = clean?
|
|
if ($puls->get('syncStatus') !== 'clean') {
|
|
throw new BadRequest('Puls hat neue Dokumente (unclean) - bitte warten Sie auf die KI-Analyse');
|
|
}
|
|
|
|
// 4. Finde Zuordnung
|
|
$zuordnung = $this->entityManager
|
|
->getRDBRepository('CPulsTeamZuordnung')
|
|
->where([
|
|
'pulsId' => $pulsId,
|
|
'teamId' => $teamId,
|
|
'aktiv' => true
|
|
])
|
|
->findOne();
|
|
|
|
if (!$zuordnung) {
|
|
throw new NotFound('Team-Zuordnung nicht gefunden oder nicht aktiv');
|
|
}
|
|
|
|
// 5. Bereits abgeschlossen?
|
|
if ($zuordnung->get('abgeschlossen')) {
|
|
return Response::json([
|
|
'success' => true,
|
|
'message' => 'Bereits abgeschlossen',
|
|
'alreadyCompleted' => true
|
|
]);
|
|
}
|
|
|
|
// 6. Abschluss setzen
|
|
$zuordnung->set([
|
|
'abgeschlossen' => true,
|
|
'abgeschlossenAm' => date('Y-m-d H:i:s'),
|
|
'abgeschlossenVonId' => $this->user->getId()
|
|
]);
|
|
|
|
$this->entityManager->saveEntity($zuordnung);
|
|
|
|
// 6.5. FIRST-READ-CLOSES: Finalisiere Block bei erstem Abschluss
|
|
if (!$puls->get('finalisiert')) {
|
|
$puls->set([
|
|
'finalisiert' => true,
|
|
'finalisierungsGrund' => 'Erstes Team',
|
|
'finalisiertAm' => date('Y-m-d H:i:s'),
|
|
'finalisiertVonId' => $this->user->getId()
|
|
]);
|
|
|
|
$this->log->info("Block finalisiert durch erstes Team (Team {$teamId}, User {$this->user->getId()})");
|
|
}
|
|
|
|
// 7. Prüfe: Alle Teams abgeschlossen?
|
|
$offeneTeams = $this->entityManager
|
|
->getRDBRepository('CPulsTeamZuordnung')
|
|
->where([
|
|
'pulsId' => $pulsId,
|
|
'aktiv' => true,
|
|
'abgeschlossen' => false
|
|
])
|
|
->count();
|
|
|
|
// 8. Update Puls-Status
|
|
if ($offeneTeams === 0) {
|
|
$puls->set('status', 'Abgeschlossen');
|
|
} else {
|
|
$puls->set('status', 'Teilweise abgeschlossen');
|
|
}
|
|
|
|
$this->entityManager->saveEntity($puls);
|
|
|
|
$this->log->info("Team {$teamId} hat Puls {$pulsId} abgeschlossen");
|
|
|
|
return Response::json([
|
|
'success' => true,
|
|
'status' => $puls->get('status'),
|
|
'finalisiert' => $puls->get('finalisiert'),
|
|
'offeneTeams' => $offeneTeams
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Route registrieren:**
|
|
|
|
```json
|
|
{
|
|
"routes": [
|
|
{
|
|
"route": "/CPuls/:id/abschliessen-fuer-team",
|
|
"method": "post",
|
|
"actionClassName": "Espo\\Custom\\Api\\CPuls\\AbschliessenFuerTeam"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4.3 API: Team zu Puls hinzufügen
|
|
|
|
**Datei:** `custom/Espo/Custom/Api/CPuls/AddTeam.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Api\CPuls;
|
|
|
|
use Espo\Core\Api\Action;
|
|
use Espo\Core\Api\Request;
|
|
use Espo\Core\Api\Response;
|
|
use Espo\Core\Exceptions\BadRequest;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
|
|
class AddTeam implements Action
|
|
{
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager,
|
|
private \Espo\Entities\User $user,
|
|
private \Espo\Core\Utils\Log $log
|
|
) {}
|
|
|
|
public function process(Request $request): Response
|
|
{
|
|
$pulsId = $request->getRouteParam('id');
|
|
$data = $request->getParsedBody();
|
|
$teamId = $data->teamId ?? null;
|
|
$prioritaet = $data->prioritaet ?? 'Normal';
|
|
|
|
if (!$pulsId || !$teamId) {
|
|
throw new BadRequest('pulsId oder teamId fehlt');
|
|
}
|
|
|
|
$puls = $this->entityManager->getEntity('CPuls', $pulsId);
|
|
|
|
if (!$puls) {
|
|
throw new NotFound('Puls nicht gefunden');
|
|
}
|
|
|
|
// Prüfe ob Team existiert
|
|
$team = $this->entityManager->getEntity('Team', $teamId);
|
|
|
|
if (!$team) {
|
|
throw new NotFound('Team nicht gefunden');
|
|
}
|
|
|
|
// Prüfe ob bereits existiert
|
|
$existing = $this->entityManager
|
|
->getRDBRepository('CPulsTeamZuordnung')
|
|
->where([
|
|
'pulsId' => $pulsId,
|
|
'teamId' => $teamId
|
|
])
|
|
->findOne();
|
|
|
|
if ($existing) {
|
|
// Reaktiviere falls inaktiv
|
|
if (!$existing->get('aktiv')) {
|
|
$existing->set([
|
|
'aktiv' => true,
|
|
'abgeschlossen' => false,
|
|
'prioritaet' => $prioritaet
|
|
]);
|
|
$this->entityManager->saveEntity($existing);
|
|
|
|
$this->log->info("Team {$teamId} reaktiviert für Puls {$pulsId}");
|
|
|
|
return Response::json([
|
|
'success' => true,
|
|
'message' => 'Team reaktiviert',
|
|
'zuordnungId' => $existing->getId()
|
|
]);
|
|
}
|
|
|
|
return Response::json([
|
|
'success' => true,
|
|
'message' => 'Team bereits aktiv',
|
|
'zuordnungId' => $existing->getId()
|
|
]);
|
|
}
|
|
|
|
// Erstelle neue Zuordnung
|
|
$zuordnung = $this->entityManager->createEntity('CPulsTeamZuordnung', [
|
|
'pulsId' => $pulsId,
|
|
'teamId' => $teamId,
|
|
'aktiv' => true,
|
|
'abgeschlossen' => false,
|
|
'prioritaet' => $prioritaet
|
|
]);
|
|
|
|
$this->log->info("Team {$teamId} hinzugefügt zu Puls {$pulsId} durch User {$this->user->getId()}");
|
|
|
|
return Response::json([
|
|
'success' => true,
|
|
'message' => 'Team hinzugefügt',
|
|
'zuordnungId' => $zuordnung->getId()
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4.4 API: Team von Puls entfernen
|
|
|
|
**Datei:** `custom/Espo/Custom/Api/CPuls/RemoveTeam.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Api\CPuls;
|
|
|
|
use Espo\Core\Api\Action;
|
|
use Espo\Core\Api\Request;
|
|
use Espo\Core\Api\Response;
|
|
use Espo\Core\Exceptions\BadRequest;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
|
|
class RemoveTeam implements Action
|
|
{
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager,
|
|
private \Espo\Entities\User $user,
|
|
private \Espo\Core\Utils\Log $log
|
|
) {}
|
|
|
|
public function process(Request $request): Response
|
|
{
|
|
$pulsId = $request->getRouteParam('id');
|
|
$data = $request->getParsedBody();
|
|
$teamId = $data->teamId ?? null;
|
|
|
|
if (!$pulsId || !$teamId) {
|
|
throw new BadRequest('pulsId oder teamId fehlt');
|
|
}
|
|
|
|
$puls = $this->entityManager->getEntity('CPuls', $pulsId);
|
|
|
|
if (!$puls) {
|
|
throw new NotFound('Puls nicht gefunden');
|
|
}
|
|
|
|
// Finde Zuordnung
|
|
$zuordnung = $this->entityManager
|
|
->getRDBRepository('CPulsTeamZuordnung')
|
|
->where([
|
|
'pulsId' => $pulsId,
|
|
'teamId' => $teamId
|
|
])
|
|
->findOne();
|
|
|
|
if (!$zuordnung) {
|
|
throw new NotFound('Team-Zuordnung nicht gefunden');
|
|
}
|
|
|
|
// Deaktiviere (soft delete)
|
|
$zuordnung->set('aktiv', false);
|
|
$this->entityManager->saveEntity($zuordnung);
|
|
|
|
$this->log->info("Team {$teamId} entfernt von Puls {$pulsId} durch User {$this->user->getId()}");
|
|
|
|
return Response::json([
|
|
'success' => true,
|
|
'message' => 'Team entfernt'
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Route registrieren in:** `custom/Espo/Custom/Resources/metadata/app/api.json`
|
|
|
|
```json
|
|
{
|
|
"routes": [
|
|
{
|
|
"route": "/CPuls/:id/aktiviere-teams",
|
|
"method": "put",
|
|
"actionClassName": "Espo\\Custom\\Api\\CPuls\\AktiviereTeams"
|
|
},
|
|
{
|
|
"route": "/CPuls/:id/abschliessen-fuer-team",
|
|
"method": "post",
|
|
"actionClassName": "Espo\\Custom\\Api\\CPuls\\AbschliessenFuerTeam"
|
|
},
|
|
{
|
|
"route": "/CPuls/:id/add-team",
|
|
"method": "post",
|
|
"actionClassName": "Espo\\Custom\\Api\\CPuls\\AddTeam"
|
|
},
|
|
{
|
|
"route": "/CPuls/:id/remove-team",
|
|
"method": "post",
|
|
"actionClassName": "Espo\\Custom\\Api\\CPuls\\RemoveTeam"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔍 Phase 5: Custom Primary Filter
|
|
|
|
### 5.1 Filter: Meine offenen Pulsen
|
|
|
|
**Datei:** `custom/Espo/Custom/Classes/Select/CPuls/PrimaryFilters/MeineOffenen.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Classes\Select\CPuls\PrimaryFilters;
|
|
|
|
use Espo\Core\Select\Primary\Filter;
|
|
use Espo\ORM\Query\SelectBuilder;
|
|
use Espo\Entities\User;
|
|
use Espo\ORM\EntityManager;
|
|
|
|
class MeineOffenen implements Filter
|
|
{
|
|
public function __construct(
|
|
private User $user,
|
|
private EntityManager $entityManager
|
|
) {}
|
|
|
|
public function apply(SelectBuilder $queryBuilder): void
|
|
{
|
|
$userId = $this->user->getId();
|
|
$userTeams = $this->user->getLinkMultipleIdList('teams');
|
|
|
|
if (empty($userTeams)) {
|
|
// User hat keine Teams -> zeige nichts
|
|
$queryBuilder->where(['id' => null]);
|
|
return;
|
|
}
|
|
|
|
// Prüfe ob User in "Anwalt"-Team ist
|
|
$anwaltTeams = $this->entityManager
|
|
->getRDBRepository('Team')
|
|
->where(['teamKategorie' => 'Anwalt'])
|
|
->select(['id'])
|
|
->find();
|
|
|
|
$anwaltTeamIds = [];
|
|
foreach ($anwaltTeams as $team) {
|
|
$anwaltTeamIds[] = $team->getId();
|
|
}
|
|
|
|
$isAnwalt = !empty(array_intersect($userTeams, $anwaltTeamIds));
|
|
|
|
// Join zu TeamZuordnungen
|
|
$queryBuilder->distinct();
|
|
$queryBuilder->leftJoin('teamZuordnungen', 'tz');
|
|
|
|
$conditions = [];
|
|
|
|
// Bedingung 1: Standard-Teams (Mandatsbetreuung, ZV)
|
|
$standardTeamIds = array_diff($userTeams, $anwaltTeamIds);
|
|
|
|
if (!empty($standardTeamIds)) {
|
|
$conditions[] = [
|
|
'tz.teamId' => $standardTeamIds,
|
|
'tz.aktiv' => true,
|
|
'tz.abgeschlossen' => false
|
|
];
|
|
}
|
|
|
|
// Bedingung 2: Anwalt-Teams (nur eigene Vorgänge)
|
|
if ($isAnwalt && !empty($anwaltTeamIds)) {
|
|
// Subquery für jede Parent-Entität
|
|
$parentConditions = [];
|
|
|
|
foreach (['CVmhRumungsklage', 'CMietinkasso', 'CKuendigung'] as $parentType) {
|
|
$alias = strtolower(str_replace('C', '', $parentType));
|
|
|
|
$queryBuilder->leftJoin(
|
|
'parent',
|
|
$alias,
|
|
[
|
|
"{$alias}.id:" => 'parentId',
|
|
'parentType' => $parentType
|
|
]
|
|
);
|
|
|
|
$parentConditions[] = [
|
|
'parentType' => $parentType,
|
|
"{$alias}.assignedUserId" => $userId
|
|
];
|
|
}
|
|
|
|
$conditions[] = [
|
|
'tz.teamId' => $anwaltTeamIds,
|
|
'tz.aktiv' => true,
|
|
'tz.abgeschlossen' => false,
|
|
'OR' => $parentConditions
|
|
];
|
|
}
|
|
|
|
if (!empty($conditions)) {
|
|
$queryBuilder->where([
|
|
'OR' => $conditions
|
|
]);
|
|
} else {
|
|
// Keine passenden Bedingungen -> zeige nichts
|
|
$queryBuilder->where(['id' => null]);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Filter registrieren:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/selectDefs/CPuls.json`
|
|
|
|
```json
|
|
{
|
|
"primaryFilterClassNameMap": {
|
|
"meineOffenen": "Espo\\Custom\\Classes\\Select\\CPuls\\PrimaryFilters\\MeineOffenen"
|
|
},
|
|
"boolFilterDefs": {
|
|
"meineOffenen": {}
|
|
}
|
|
}
|
|
```
|
|
|
|
**i18n:**
|
|
|
|
```json
|
|
{
|
|
"presetFilters": {
|
|
"meineOffenen": "Meine offenen Pulsen"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 Phase 6: Middleware-Integration (Spezifikation)
|
|
|
|
### 6.1 Polling-Endpoints
|
|
|
|
**Middleware nutzt Standard-EspoCRM-API:**
|
|
|
|
#### Neue Dokumente ohne Puls finden:
|
|
```http
|
|
GET /api/v1/CDokumente?where[0][type]=isNull&where[0][attribute]=pulsId&maxSize=50&orderBy=createdAt&order=asc
|
|
```
|
|
|
|
#### Pulsen mit unclean Status:
|
|
```http
|
|
GET /api/v1/CPuls?where[0][type]=equals&where[0][attribute]=syncStatus&where[0][value]=unclean&maxSize=10
|
|
```
|
|
|
|
#### Abwesende User:
|
|
```http
|
|
GET /api/v1/User?where[0][type]=equals&where[0][attribute]=abwesend&where[0][value]=true
|
|
```
|
|
|
|
---
|
|
|
|
### 6.2 Middleware-Workflow: Dokument-Verarbeitung
|
|
|
|
**Pseudocode:**
|
|
```
|
|
POLLING JOB (alle 60 Sekunden):
|
|
|
|
1. Query neue Dokumente ohne Puls
|
|
|
|
2. Für jedes Dokument:
|
|
a) Hat es einen Parent?
|
|
NEIN → Skip (keine Zuordnung möglich)
|
|
|
|
b) Existiert offene Puls für diesen Parent?
|
|
Query: /api/v1/CPuls?where[0][parentId]={id}&where[1][finalisiert]=false
|
|
|
|
JA (und finalisiert=false) →
|
|
- Dokument verknüpfen: PUT /api/v1/CDokumente/{id} {"pulsId": X}
|
|
- Puls-Status: PUT /api/v1/CPuls/{id} {"status": "In Verarbeitung", "syncStatus": "unclean"}
|
|
|
|
NEIN (oder finalisiert=true) →
|
|
- Neue Puls erstellen:
|
|
POST /api/v1/CPuls {
|
|
"name": "Puls #N - [Datum]",
|
|
"parentType": "...",
|
|
"parentId": "...",
|
|
"status": "Neu",
|
|
"syncStatus": "unclean",
|
|
"finalisiert": false
|
|
}
|
|
- Dokument verknüpfen
|
|
|
|
3. Queue für KI-Analyse füllen
|
|
|
|
ANALYSE JOB (async Worker):
|
|
|
|
1. Hole Puls aus Queue
|
|
|
|
2. Download alle Dokumente:
|
|
GET /api/v1/CPuls/{id}/dokumente
|
|
Für jedes: GET /api/v1/Attachment/{attachmentId}
|
|
|
|
3. KI-Verarbeitung:
|
|
- OCR falls nötig
|
|
- Inhaltsanalyse
|
|
- Team-Entscheidung:
|
|
* Regex/Keywords für Zwangsvollstreckung
|
|
* Sentiment-Analyse für Dringlichkeit
|
|
* Named-Entity-Recognition für Beteiligte
|
|
- Priorität ableiten
|
|
|
|
4. Update via Custom API:
|
|
PUT /api/v1/CPuls/{id}/aktiviere-teams {
|
|
"kiAnalyse": "Lange Zusammenfassung...",
|
|
"zusammenfassung": "Kurz...",
|
|
"status": "Bereit",
|
|
"syncStatus": "clean",
|
|
"teams": [
|
|
{"teamId": "66ab...", "prioritaet": "Hoch"}
|
|
]
|
|
}
|
|
|
|
5. Optional: Benachrichtigungen triggern
|
|
```
|
|
|
|
---
|
|
|
|
### 6.3 Middleware-Workflow: Abwesenheitsvertretung
|
|
|
|
**Pseudocode:**
|
|
```
|
|
POLLING JOB (alle 5 Minuten):
|
|
|
|
1. Query abwesende User
|
|
|
|
2. Für jeden User:
|
|
a) Prüfe abwesendBis:
|
|
Wenn abwesendBis <= heute:
|
|
- PUT /api/v1/User/{id} {"abwesend": false}
|
|
- Skip (User ist zurück)
|
|
|
|
b) Ermittle Vertreter:
|
|
- Prio 1: User.vertretung (falls gesetzt)
|
|
- Prio 2: Team-Leader (Query: Team mit User als Member)
|
|
- Prio 3: User mit wenigsten offenen Pulsen
|
|
|
|
c) Query offene Pulsen für Anwalt-Teams:
|
|
GET /api/v1/CPuls
|
|
?where[0][type]=in
|
|
&where[0][attribute]=parentType
|
|
&where[0][value][]=CVmhRumungsklage
|
|
&...
|
|
Filtere lokal nach: Parent.assignedUserId = abwesenderUser
|
|
|
|
d) Für jede Puls:
|
|
- Update Parent-Vorgang:
|
|
PUT /api/v1/{ParentType}/{parentId} {
|
|
"assignedUserId": "vertreterUserId"
|
|
}
|
|
- Stream-Eintrag erstellen:
|
|
POST /api/v1/Note {
|
|
"parentType": "CPuls",
|
|
"parentId": "{pulsId}",
|
|
"type": "Post",
|
|
"post": "Umverteilt von [Abwesender] zu [Vertreter] (Abwesenheit)"
|
|
}
|
|
|
|
e) Analog für Tasks, Workflows, etc.
|
|
```
|
|
|
|
---
|
|
|
|
## ⚡ Phase 7: Erweiterte Features
|
|
|
|
### 7.1 Feature: Team-basierte vs. Persönliche Filter
|
|
|
|
**Ziel:** User sollen zwischen "Teams-Posteingang" (alle Pulsen des Teams) und "Nur meine" (nur zugewiesene) wechseln können.
|
|
|
|
**Implementation:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Classes/Select/CPuls/PrimaryFilters/NurMeine.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Classes\Select\CPuls\PrimaryFilters;
|
|
|
|
use Espo\Core\Select\Primary\Filter;
|
|
use Espo\ORM\Query\SelectBuilder;
|
|
use Espo\Entities\User;
|
|
|
|
class NurMeine implements Filter
|
|
{
|
|
public function __construct(
|
|
private User $user
|
|
) {}
|
|
|
|
public function apply(SelectBuilder $queryBuilder): void
|
|
{
|
|
$userId = $this->user->getId();
|
|
|
|
// Nur Pulsen, bei denen User direkt zugewiesen ist
|
|
$queryBuilder->where([
|
|
'assignedUserId' => $userId
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Registrierung in selectDefs:**
|
|
|
|
```json
|
|
{
|
|
"primaryFilterClassNameMap": {
|
|
"meineOffenen": "Espo\\Custom\\Classes\\Select\\CPuls\\PrimaryFilters\\MeineOffenen",
|
|
"nurMeine": "Espo\\Custom\\Classes\\Select\\CPuls\\PrimaryFilters\\NurMeine"
|
|
}
|
|
}
|
|
```
|
|
|
|
**i18n:**
|
|
|
|
```json
|
|
{
|
|
"presetFilters": {
|
|
"meineOffenen": "Teams-Posteingang",
|
|
"nurMeine": "Nur meine Pulsen"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Logik:**
|
|
- "Teams-Posteingang" (meineOffenen) → Filtert nach Team-Zuordnungen (wie bisher)
|
|
- "Nur meine" (nurMeine) → Filtert nach assignedUserId = currentUser
|
|
- Middleware kann bei Zuweisung eines Teams optional einen User als assignedUser setzen
|
|
|
|
---
|
|
|
|
### 7.2 Feature: Task/Call Integration mit Status-Flow
|
|
|
|
**Ziel:** KI schlägt Tasks/Calls vor (Status: "vorgeschlagen"), die automatisch genehmigt werden, wenn die Puls als gelesen markiert wird.
|
|
|
|
**Task-Entity erweitern:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/Task.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"puls": {
|
|
"type": "link",
|
|
"entity": "CPuls",
|
|
"isCustom": true
|
|
},
|
|
"genehmigungsstatus": {
|
|
"type": "enum",
|
|
"options": [
|
|
"Vorgeschlagen",
|
|
"Genehmigt",
|
|
"Abgelehnt"
|
|
],
|
|
"default": "Vorgeschlagen",
|
|
"isCustom": true,
|
|
"style": {
|
|
"Vorgeschlagen": "warning",
|
|
"Genehmigt": "success",
|
|
"Abgelehnt": "danger"
|
|
}
|
|
}
|
|
},
|
|
"links": {
|
|
"puls": {
|
|
"type": "belongsTo",
|
|
"entity": "CPuls",
|
|
"isCustom": true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Hook: Auto-Approve bei Puls abgeschlossen**
|
|
|
|
**Datei:** `custom/Espo/Custom/Hooks/CPuls/AutoApproveTasksOnComplete.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Hooks\CPuls;
|
|
|
|
use Espo\ORM\Entity;
|
|
use Espo\Core\Hook\Hook\AfterSave;
|
|
|
|
class AutoApproveTasksOnComplete implements AfterSave
|
|
{
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager,
|
|
private \Espo\Core\Utils\Log $log
|
|
) {}
|
|
|
|
public function afterSave(Entity $entity, array $options): void
|
|
{
|
|
// Nur bei Status-Änderung zu "Bereit" oder "In Review"
|
|
if (!$entity->isAttributeChanged('status')) {
|
|
return;
|
|
}
|
|
|
|
$status = $entity->get('status');
|
|
|
|
if (!in_array($status, ['Bereit', 'In Review'])) {
|
|
return;
|
|
}
|
|
|
|
// Finde alle vorgeschlagenen Tasks
|
|
$tasks = $this->entityManager
|
|
->getRDBRepository('Task')
|
|
->where([
|
|
'pulsId' => $entity->getId(),
|
|
'genehmigungsstatus' => 'Vorgeschlagen'
|
|
])
|
|
->find();
|
|
|
|
foreach ($tasks as $task) {
|
|
$task->set('genehmigungsstatus', 'Genehmigt');
|
|
$this->entityManager->saveEntity($task);
|
|
}
|
|
|
|
if (count($tasks) > 0) {
|
|
$this->log->info("Auto-approved " . count($tasks) . " tasks for Puls " . $entity->getId());
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Analog für Call-Entity implementieren**
|
|
|
|
---
|
|
|
|
**Problem: Parent-Hierarchie**
|
|
|
|
Tasks/Calls sind mit CPuls verknüpft (belongsToParent), nicht direkt mit dem übergeordneten Vorgang (CVmhRumungsklage etc.). Das bedeutet:
|
|
- Task.parent = CPuls
|
|
- CPuls.parent = CVmhRumungsklage
|
|
- **Aber:** Task wird NICHT automatisch in CVmhRumungsklage angezeigt
|
|
|
|
**Lösung: Report Panels**
|
|
|
|
Report Panels können über Subqueries arbeiten und Tasks/Calls aus allen zugehörigen Pulsen anzeigen.
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/CVmhRumungsklage.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"pulseTasks": {
|
|
"type": "linkMultiple",
|
|
"notStorable": true,
|
|
"readOnly": true,
|
|
"layoutDetailDisabled": true,
|
|
"layoutListDisabled": true,
|
|
"layoutMassUpdateDisabled": true
|
|
},
|
|
"pulseCalls": {
|
|
"type": "linkMultiple",
|
|
"notStorable": true,
|
|
"readOnly": true,
|
|
"layoutDetailDisabled": true,
|
|
"layoutListDisabled": true,
|
|
"layoutMassUpdateDisabled": true
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Report Panel Definition:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/clientDefs/CVmhRumungsklage.json`
|
|
|
|
```json
|
|
{
|
|
"bottomPanels": {
|
|
"pulseTasks": {
|
|
"name": "pulseTasks",
|
|
"label": "Tasks aus Pulsen",
|
|
"view": "views/record/panels/relationship",
|
|
"recordListView": "views/record/list",
|
|
"select": false,
|
|
"create": false,
|
|
"rowActionsView": "views/record/row-actions/relationship",
|
|
"filterList": ["open", "completed"],
|
|
"orderBy": "dateStart",
|
|
"order": "desc"
|
|
},
|
|
"pulseCalls": {
|
|
"name": "pulseCalls",
|
|
"label": "Anrufe aus Pulsen",
|
|
"view": "views/record/panels/relationship",
|
|
"recordListView": "views/record/list",
|
|
"select": false,
|
|
"create": false,
|
|
"rowActionsView": "views/record/row-actions/relationship",
|
|
"filterList": ["planned", "held"],
|
|
"orderBy": "dateStart",
|
|
"order": "desc"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Custom Select Manager für Tasks-Subquery:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Classes/Select/CVmhRumungsklage/AdditionalAppliers/PulseTasks.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Classes\Select\CVmhRumungsklage\AdditionalAppliers;
|
|
|
|
use Espo\Core\Select\Applier\AdditionalApplier;
|
|
use Espo\ORM\Query\SelectBuilder;
|
|
|
|
class PulseTasks implements AdditionalApplier
|
|
{
|
|
public function apply(SelectBuilder $queryBuilder, string $relationName): void
|
|
{
|
|
if ($relationName !== 'pulseTasks') {
|
|
return;
|
|
}
|
|
|
|
// Subquery: Finde alle Pulsen für diesen Vorgang
|
|
$queryBuilder->where([
|
|
'id=s' => [
|
|
'from' => 'Task',
|
|
'select' => ['id'],
|
|
'whereClause' => [
|
|
'parentType' => 'CPuls',
|
|
'parentId=s' => [
|
|
'from' => 'CPuls',
|
|
'select' => ['id'],
|
|
'whereClause' => [
|
|
'parentType' => 'CVmhRumungsklage',
|
|
'parentId' => '{alias}.id'
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Custom Select Manager für Calls-Subquery:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Classes/Select/CVmhRumungsklage/AdditionalAppliers/PulseCalls.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Classes\Select\CVmhRumungsklage\AdditionalAppliers;
|
|
|
|
use Espo\Core\Select\Applier\AdditionalApplier;
|
|
use Espo\ORM\Query\SelectBuilder;
|
|
|
|
class PulseCalls implements AdditionalApplier
|
|
{
|
|
public function apply(SelectBuilder $queryBuilder, string $relationName): void
|
|
{
|
|
if ($relationName !== 'pulseCalls') {
|
|
return;
|
|
}
|
|
|
|
// Subquery: Finde alle Pulsen für diesen Vorgang
|
|
$queryBuilder->where([
|
|
'id=s' => [
|
|
'from' => 'Call',
|
|
'select' => ['id'],
|
|
'whereClause' => [
|
|
'parentType' => 'CPuls',
|
|
'parentId=s' => [
|
|
'from' => 'CPuls',
|
|
'select' => ['id'],
|
|
'whereClause' => [
|
|
'parentType' => 'CVmhRumungsklage',
|
|
'parentId' => '{alias}.id'
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**SelectDefs Registrierung:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/selectDefs/CVmhRumungsklage.json`
|
|
|
|
```json
|
|
{
|
|
"additionalAppliers": {
|
|
"pulseTasks": "Espo\\Custom\\Classes\\Select\\CVmhRumungsklage\\AdditionalAppliers\\PulseTasks",
|
|
"pulseCalls": "Espo\\Custom\\Classes\\Select\\CVmhRumungsklage\\AdditionalAppliers\\PulseCalls"
|
|
}
|
|
}
|
|
```
|
|
|
|
**i18n:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/CVmhRumungsklage.json`
|
|
|
|
```json
|
|
{
|
|
"links": {
|
|
"pulseTasks": "Tasks aus Pulsen",
|
|
"pulseCalls": "Anrufe aus Pulsen"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Analog implementieren für:**
|
|
- `CMietinkasso` (gleiche Struktur, nur Entity-Name anpassen)
|
|
- `CKuendigung` (gleiche Struktur, nur Entity-Name anpassen)
|
|
|
|
**Vorteile:**
|
|
- User sehen alle Tasks/Calls aus Pulsen im übergeordneten Vorgang
|
|
- Filterung möglich (offen/abgeschlossen)
|
|
- Keine Duplikate durch eindeutige Subquery
|
|
- Read-Only Panel (keine versehentlichen Änderungen)
|
|
|
|
**Hinweis:** Tasks/Calls werden zweimal angezeigt:
|
|
1. Im Standard-Panel (direkt mit Vorgang verknüpft)
|
|
2. Im Report-Panel (über Pulsen verknüpft)
|
|
|
|
Dies ist beabsichtigt, da beide Verknüpfungsarten parallel existieren können.
|
|
|
|
---
|
|
|
|
### 7.3 Feature: KI-User Action Catalog
|
|
|
|
**Ziel:** User können dem KI-User Aufgaben aus einem vordefinierten Katalog zuweisen. Middleware führt diese automatisiert aus.
|
|
|
|
**Entity: CKiAktion**
|
|
|
|
**Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/CKiAktion.json`
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"name": {
|
|
"type": "varchar",
|
|
"required": true,
|
|
"isCustom": true
|
|
},
|
|
"puls": {
|
|
"type": "link",
|
|
"entity": "CPuls",
|
|
"required": true,
|
|
"isCustom": true
|
|
},
|
|
"aktionstyp": {
|
|
"type": "enum",
|
|
"options": [
|
|
"Dokumente zusammenfassen",
|
|
"Frist prüfen",
|
|
"E-Mail-Entwurf erstellen",
|
|
"Aktenbezug suchen",
|
|
"Zahlungseingang prüfen",
|
|
"Mahnung vorbereiten"
|
|
],
|
|
"required": true,
|
|
"isCustom": true
|
|
},
|
|
"status": {
|
|
"type": "enum",
|
|
"options": [
|
|
"Wartend",
|
|
"In Bearbeitung",
|
|
"Abgeschlossen",
|
|
"Fehler"
|
|
],
|
|
"default": "Wartend",
|
|
"isCustom": true,
|
|
"style": {
|
|
"Wartend": "default",
|
|
"In Bearbeitung": "primary",
|
|
"Abgeschlossen": "success",
|
|
"Fehler": "danger"
|
|
}
|
|
},
|
|
"ergebnis": {
|
|
"type": "text",
|
|
"isCustom": true
|
|
},
|
|
"fehlerMeldung": {
|
|
"type": "text",
|
|
"isCustom": true
|
|
},
|
|
"assignedUser": {
|
|
"type": "link",
|
|
"entity": "User",
|
|
"isCustom": true
|
|
}
|
|
},
|
|
"links": {
|
|
"puls": {
|
|
"type": "belongsTo",
|
|
"entity": "CPuls"
|
|
},
|
|
"assignedUser": {
|
|
"type": "belongsTo",
|
|
"entity": "User"
|
|
}
|
|
},
|
|
"collection": {
|
|
"orderBy": "createdAt",
|
|
"order": "desc"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Custom View: Dropdown im Detail-View**
|
|
|
|
**Datei:** `client/custom/src/views/c-puls/record/detail.js`
|
|
|
|
```javascript
|
|
define('custom:views/c-puls/record/detail', ['views/record/detail'], function (Dep) {
|
|
return Dep.extend({
|
|
|
|
setup: function () {
|
|
Dep.prototype.setup.call(this);
|
|
|
|
// Prüfe ob KI-User existiert und aktiv
|
|
this.wait(
|
|
this.getModelFactory().create('User').then(user => {
|
|
user.fetch().then(() => {
|
|
const kiUserId = this.getConfig().get('kiUserId');
|
|
|
|
if (kiUserId) {
|
|
this.addKiActionButton();
|
|
}
|
|
});
|
|
})
|
|
);
|
|
},
|
|
|
|
addKiActionButton: function () {
|
|
this.addButton({
|
|
name: 'kiAktionErstellen',
|
|
label: 'KI-Aktion zuweisen',
|
|
style: 'warning',
|
|
onClick: () => this.showKiActionModal()
|
|
});
|
|
},
|
|
|
|
showKiActionModal: function () {
|
|
this.createView('dialog', 'custom:views/modals/ki-aktion-auswahl', {
|
|
pulsId: this.model.id,
|
|
pulsName: this.model.get('name')
|
|
}, view => {
|
|
view.render();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
**Modal für Aktions-Auswahl:**
|
|
|
|
**Datei:** `client/custom/src/views/modals/ki-aktion-auswahl.js`
|
|
|
|
```javascript
|
|
define('custom:views/modals/ki-aktion-auswahl', ['views/modal', 'model'], function (Dep, Model) {
|
|
return Dep.extend({
|
|
|
|
template: 'custom:modals/ki-aktion-auswahl',
|
|
|
|
data: function () {
|
|
return {
|
|
pulsName: this.options.pulsName,
|
|
aktionstypen: this.getMetadata().get(['entityDefs', 'CKiAktion', 'fields', 'aktionstyp', 'options'])
|
|
};
|
|
},
|
|
|
|
setup: function () {
|
|
this.headerText = 'KI-Aktion zuweisen';
|
|
|
|
this.buttonList = [
|
|
{
|
|
name: 'create',
|
|
label: 'Erstellen',
|
|
style: 'primary',
|
|
onClick: () => this.create()
|
|
},
|
|
{
|
|
name: 'cancel',
|
|
label: 'Abbrechen'
|
|
}
|
|
];
|
|
},
|
|
|
|
create: function () {
|
|
const aktionstyp = this.$el.find('[name="aktionstyp"]').val();
|
|
|
|
if (!aktionstyp) {
|
|
Espo.Ui.error('Bitte wählen Sie einen Aktionstyp');
|
|
return;
|
|
}
|
|
|
|
const kiUserId = this.getConfig().get('kiUserId');
|
|
|
|
this.ajaxPostRequest('CKiAktion', {
|
|
name: `${aktionstyp} - ${this.options.pulsName}`,
|
|
pulsId: this.options.pulsId,
|
|
aktionstyp: aktionstyp,
|
|
assignedUserId: kiUserId,
|
|
status: 'Wartend'
|
|
}).then(() => {
|
|
Espo.Ui.success('KI-Aktion wurde erstellt');
|
|
this.trigger('created');
|
|
this.close();
|
|
});
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
**Middleware: Task-Executor**
|
|
|
|
```python
|
|
# middleware/ki_task_executor.py
|
|
|
|
import time
|
|
import requests
|
|
from typing import Dict, Any
|
|
|
|
class KiTaskExecutor:
|
|
def __init__(self, espocrm_api: EspoCrmApi):
|
|
self.api = espocrm_api
|
|
|
|
def poll_pending_actions(self):
|
|
"""Pollt alle 30 Sekunden nach wartenden KI-Aktionen"""
|
|
while True:
|
|
try:
|
|
actions = self.api.get('CKiAktion', params={
|
|
'where': [{'type': 'equals', 'attribute': 'status', 'value': 'Wartend'}],
|
|
'maxSize': 10
|
|
})
|
|
|
|
for action in actions.get('list', []):
|
|
self.execute_action(action)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error polling KI actions: {e}")
|
|
|
|
time.sleep(30)
|
|
|
|
def execute_action(self, action: Dict[str, Any]):
|
|
"""Führt eine KI-Aktion aus"""
|
|
action_id = action['id']
|
|
aktionstyp = action['aktionstyp']
|
|
puls_id = action['pulsId']
|
|
|
|
# Update Status
|
|
self.api.put(f'CKiAktion/{action_id}', {'status': 'In Bearbeitung'})
|
|
|
|
try:
|
|
# Route basierend auf Aktionstyp
|
|
if aktionstyp == 'Dokumente zusammenfassen':
|
|
result = self.summarize_documents(puls_id)
|
|
elif aktionstyp == 'Frist prüfen':
|
|
result = self.check_deadlines(puls_id)
|
|
elif aktionstyp == 'E-Mail-Entwurf erstellen':
|
|
result = self.create_email_draft(puls_id)
|
|
# ... weitere Aktionstypen
|
|
else:
|
|
result = f"Aktionstyp {aktionstyp} noch nicht implementiert"
|
|
|
|
# Update mit Ergebnis
|
|
self.api.put(f'CKiAktion/{action_id}', {
|
|
'status': 'Abgeschlossen',
|
|
'ergebnis': result
|
|
})
|
|
|
|
except Exception as e:
|
|
self.api.put(f'CKiAktion/{action_id}', {
|
|
'status': 'Fehler',
|
|
'fehlerMeldung': str(e)
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
### 7.4 UI-Anpassungen für Features
|
|
|
|
**Button: Team hinzufügen/entfernen**
|
|
|
|
```javascript
|
|
// client/custom/src/views/c-puls/record/detail.js (erweitern)
|
|
|
|
addTeamManagementButtons: function () {
|
|
this.addButton({
|
|
name: 'addTeam',
|
|
label: 'Team hinzufügen',
|
|
style: 'default',
|
|
onClick: () => this.showAddTeamModal()
|
|
});
|
|
},
|
|
|
|
showAddTeamModal: function () {
|
|
// Team-Auswahl Modal
|
|
this.createView('dialog', 'views/modals/select-records', {
|
|
scope: 'Team',
|
|
multiple: false
|
|
}, view => {
|
|
view.render();
|
|
|
|
this.listenToOnce(view, 'select', team => {
|
|
this.ajaxPostRequest(`CPuls/${this.model.id}/add-team`, {
|
|
teamId: team.id,
|
|
prioritaet: 'Normal'
|
|
}).then(() => {
|
|
Espo.Ui.success('Team hinzugefügt');
|
|
this.model.fetch();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Phase 8: Testing & Qualitätssicherung
|
|
|
|
### 7.1 Unit-Tests (Custom PHP-Klassen)
|
|
|
|
**Datei:** `tests/unit/Espo/Custom/Api/CPuls/AktiviereTeamsTest.php`
|
|
|
|
**Test-Cases:**
|
|
- ✅ Teams werden korrekt aktiviert
|
|
- ✅ Alte Zuordnungen werden deaktiviert
|
|
- ✅ Puls-Status wird aktualisiert
|
|
- ✅ Fehlerbehandlung bei fehlender ID
|
|
- ✅ Fehlerbehandlung bei ungültigem Team
|
|
|
|
---
|
|
|
|
### 7.2 Integration-Tests
|
|
|
|
**Szenario 1: Dokument → Puls → Analyse → Abschluss**
|
|
1. Upload Dokument via UI
|
|
2. Middleware erkennt Dokument (manuell triggern)
|
|
3. Middleware erstellt Puls
|
|
4. Middleware analysiert & aktiviert Teams
|
|
5. User reviewed & schließt ab
|
|
6. Validierung: Status = "Abgeschlossen"
|
|
|
|
**Szenario 2: Mehrere Teams parallel**
|
|
1. Puls mit 2 Teams (Mandatsbetreuung + Anwalt)
|
|
2. Mandatsbetreuung schließt ab → Status = "Teilweise abgeschlossen"
|
|
3. Anwalt schließt ab → Status = "Abgeschlossen"
|
|
|
|
**Szenario 3: First-Read-Closes Prinzip**
|
|
1. Puls mit 2 Teams (Mandatsbetreuung + Anwalt)
|
|
2. Mandatsbetreuung schließt ab → finalisiert=true, finalisierungsGrund="Erstes Team"
|
|
3. Neues Dokument wird uploaded
|
|
4. Middleware erkennt: Puls finalisiert=true
|
|
5. Middleware erstellt NEUE Puls für das neue Dokument
|
|
6. Validierung: Alte Puls bleibt geschlossen, neue Puls existiert
|
|
|
|
**Szenario 4: Neues Dokument während Review (unfinalisiert)**
|
|
1. Puls im Status "Bereit", finalisiert=false
|
|
2. Neues Dokument uploaded
|
|
3. Middleware setzt syncStatus = "unclean"
|
|
4. Abschluss-Button disabled (Formula Script)
|
|
5. Middleware re-analysiert
|
|
6. syncStatus = "clean", Abschluss wieder möglich
|
|
|
|
**Szenario 5: Abwesenheitsvertretung**
|
|
1. User A setzt abwesend = true, vertretung = User B
|
|
2. Middleware pollt
|
|
3. Räumungsklagen von User A werden umverteilt
|
|
4. Pulsen erscheinen in User B's Liste
|
|
5. User A setzt abwesend = false
|
|
6. Keine weitere Umverteilung
|
|
|
|
**Szenario 6: Team-Management durch User**
|
|
1. User öffnet Puls
|
|
2. Klickt "Team hinzufügen"
|
|
3. Wählt Team "Zwangsvollstreckung"
|
|
4. Validierung: TeamZuordnung erstellt, aktiv=true
|
|
5. Klickt "Team entfernen" für dieses Team
|
|
6. Validierung: TeamZuordnung.aktiv=false
|
|
|
|
**Szenario 7: Task-Genehmigung**
|
|
1. Middleware erstellt Task mit genehmigungsstatus="Vorgeschlagen"
|
|
2. User sieht Task in Puls (gelb markiert)
|
|
3. User öffnet Puls → Status wird "In Review"
|
|
4. Hook: Alle Tasks mit genehmigungsstatus="Vorgeschlagen" → "Genehmigt"
|
|
5. Validierung: Tasks sind grün markiert
|
|
|
|
**Szenario 8: KI-Aktion Katalog**
|
|
1. User öffnet Puls
|
|
2. Klickt "KI-Aktion zuweisen"
|
|
3. Wählt "Dokumente zusammenfassen"
|
|
4. CKiAktion erstellt mit status="Wartend"
|
|
5. Middleware pollt, findet Aktion
|
|
6. Middleware führt aus → status="Abgeschlossen", ergebnis gefüllt
|
|
7. User sieht Ergebnis in Stream/Panel
|
|
|
|
**Szenario 9: Report Panel - Tasks aus Pulsen**
|
|
1. Puls mit parentType="CVmhRumungsklage", parentId=X erstellt
|
|
2. Task mit parentType="CPuls", parentId=Y erstellt (vorgeschlagen)
|
|
3. User öffnet CVmhRumungsklage (ID=X)
|
|
4. Sieht Bottom-Panel "Tasks aus Pulsen"
|
|
5. Task wird angezeigt trotz parent=CPuls
|
|
6. Filter "Offen" zeigt nur nicht-abgeschlossene Tasks
|
|
7. Validierung: Subquery funktioniert, Task ist sichtbar
|
|
|
|
---
|
|
|
|
### 7.3 Performance-Tests
|
|
|
|
**Metriken:**
|
|
- Query-Zeit für "Meine offenen" Filter < 500ms (bei 1000 Pulsen)
|
|
- Middleware Polling-Overhead < 1 CPU-Sekunde pro Cycle
|
|
- Abschluss-API < 200ms Response-Time
|
|
|
|
---
|
|
|
|
## 📋 Deployment-Checkliste
|
|
|
|
### Pre-Deployment
|
|
- [ ] Alle JSON-Dateien validiert (Syntax)
|
|
- [ ] Relationships bidirektional definiert
|
|
- [ ] i18n vollständig (de_DE + en_US)
|
|
- [ ] Custom API-Routes registriert
|
|
- [ ] PHP-Klassen Namespace korrekt
|
|
|
|
### Deployment
|
|
- [ ] Files via Git committen
|
|
- [ ] `python3 custom/scripts/validate_and_rebuild.py` ausführen
|
|
- [ ] Keine Errors in Validation
|
|
- [ ] Rebuild erfolgreich
|
|
- [ ] Browser Hard Refresh (Ctrl+Shift+R)
|
|
|
|
### Post-Deployment
|
|
- [ ] Teams kategorisieren (teamKategorie setzen)
|
|
- [ ] KI-User erstellen und ID in Config eintragen
|
|
- [ ] Test-Puls manuell erstellen
|
|
- [ ] Filter "Meine offenen" und "Nur meine" testen
|
|
- [ ] API-Endpoints mit curl/Postman testen (inkl. add-team, remove-team)
|
|
- [ ] Task mit genehmigungsstatus="Vorgeschlagen" erstellen und Auto-Approve testen
|
|
- [ ] Report Panels testen: Task zu Puls erstellen → in übergeordnetem Vorgang sichtbar
|
|
- [ ] Report Panels für alle 3 Parent-Types testen (CVmhRumungsklage, CMietinkasso, CKuendigung)
|
|
- [ ] KI-Aktion aus Katalog zuweisen und Middleware-Ausführung testen
|
|
- [ ] First-Read-Closes Prinzip validieren (Block finalisieren, neues Dokument → neuer Block)
|
|
- [ ] Middleware konfigurieren & starten (inkl. KI-Task-Executor)
|
|
- [ ] End-to-End-Test durchführen
|
|
|
|
---
|
|
|
|
## 🔄 Rollback-Plan
|
|
|
|
**Bei Problemen:**
|
|
1. Git: `git revert HEAD` (letzten Commit rückgängig)
|
|
2. Rebuild: `python3 custom/scripts/validate_and_rebuild.py`
|
|
3. Cache leeren: `rm -rf data/cache/*`
|
|
4. Middleware stoppen
|
|
5. DB-Rollback falls nötig: `DROP TABLE c_puls, c_puls_team_zuordnung;`
|
|
|
|
---
|
|
|
|
## 📚 Dokumentation & Wissenstransfer
|
|
|
|
### Für Entwickler
|
|
- Dieser Plan (`ENTWICKLUNGSPLAN_ENTWICKLUNGEN.md`)
|
|
- Code-Kommentare in PHP-Klassen
|
|
- API-Dokumentation (Swagger/Postman Collection)
|
|
|
|
### Für User
|
|
- User-Guide: "Wie nutze ich Pulsen?"
|
|
- Video-Tutorial: Puls reviewen & abschließen
|
|
- FAQ: Häufige Fragen
|
|
|
|
### Für Admins
|
|
- Team-Setup-Guide (teamKategorie konfigurieren)
|
|
- Middleware-Setup-Guide
|
|
- Troubleshooting-Guide
|
|
|
|
---
|
|
|
|
## 🎯 Success Metrics
|
|
|
|
**Funktional:**
|
|
- ✅ Dokumente werden automatisch zu Pulsen gruppiert
|
|
- ✅ First-Read-Closes: Block wird beim ersten Abschluss finalisiert
|
|
- ✅ Neue Dokumente nach Finalisierung → neuer Block
|
|
- ✅ KI-Analyse wird angezeigt
|
|
- ✅ Teams sehen nur relevante Pulsen
|
|
- ✅ Filter "Teams-Posteingang" vs "Nur meine" funktionieren
|
|
- ✅ Team-Management: Add/Remove durch User möglich
|
|
- ✅ Task-Genehmigung: Automatisch bei Puls-Review
|
|
- ✅ Report Panels: Tasks/Calls aus Pulsen in übergeordneten Vorgängen sichtbar
|
|
- ✅ KI-Aktionen: Katalog verfügbar, Middleware führt aus
|
|
- ✅ Abschluss-Workflow funktioniert
|
|
- ✅ Abwesenheitsvertretung funktioniert
|
|
|
|
**Performance:**
|
|
- ✅ Filter < 500ms
|
|
- ✅ API-Calls < 200ms
|
|
- ✅ Middleware-Polling ohne Fehler
|
|
|
|
**Usability:**
|
|
- ✅ User finden Pulsen intuitiv
|
|
- ✅ Abschluss-Prozess klar
|
|
- ✅ Fehler-Messages verständlich
|
|
|
|
---
|
|
|
|
## 🔮 Zukünftige Erweiterungen (Roadmap)
|
|
|
|
### v2.0: UI/UX Verbesserungen
|
|
- [ ] Kommentare zu Pulsen (Stream) - IMPLEMENTIERT
|
|
- [ ] E-Mail-Benachrichtigungen bei neuen Pulsen
|
|
- [ ] Dashboard-Widget: "Meine offenen Pulsen"
|
|
- [ ] Bulk-Actions: Mehrere Pulsen gleichzeitig abschließen
|
|
- [ ] Drag & Drop für Team-Prioritäten
|
|
- [ ] Inline-Editing für Team-Zuordnungen
|
|
|
|
### v2.5: Analytics
|
|
- [ ] Report: Durchschnittliche Bearbeitungszeit pro Team
|
|
- [ ] Report: Anzahl Pulsen pro Vorgang
|
|
- [ ] Dashboard: Pulsen-Pipeline (Kanban-View)
|
|
|
|
### v3.0: Advanced
|
|
- [ ] Workflow-Integration: Auto-Task bei neuer Puls
|
|
- [ ] Custom Notification-Channels (Slack, Teams)
|
|
- [ ] Mobile-App-Integration
|
|
- [ ] KI-gestützte Prioritäts-Vorhersage
|
|
|
|
---
|
|
|
|
## 👥 Rollen & Verantwortlichkeiten
|
|
|
|
**Backend-Entwickler:**
|
|
- Entity-Definitionen
|
|
- Custom API-Endpoints
|
|
- Hooks & Formula-Scripts
|
|
- Performance-Optimierung
|
|
|
|
**Frontend-Entwickler:**
|
|
- Layouts (Detail, List, Panels)
|
|
- Custom Views (falls nötig)
|
|
- CSS-Anpassungen
|
|
- UI/UX-Testing
|
|
|
|
**Middleware-Entwickler:**
|
|
- Polling-Jobs
|
|
- KI-Integration
|
|
- Abwesenheits-Logik
|
|
- Error-Handling
|
|
|
|
**QA-Engineer:**
|
|
- Test-Cases erstellen
|
|
- Integration-Tests
|
|
- Performance-Tests
|
|
- Bug-Tracking
|
|
|
|
**Product Owner:**
|
|
- Requirements validieren
|
|
- User-Feedback einholen
|
|
- Prioritäten setzen
|
|
- Acceptance-Tests
|
|
|
|
---
|
|
|
|
## 📞 Support & Kontakt
|
|
|
|
**Bei Fragen zur Implementierung:**
|
|
- EspoCRM-Dokumentation: https://docs.espocrm.com
|
|
- Custom Development Guide: `README.md`
|
|
- KI-Overview-Script: `bash custom/scripts/ki-overview.sh`
|
|
|
|
**Bei Problemen:**
|
|
- Logs prüfen: `tail -f data/logs/espo-*.log`
|
|
- Validator: `python3 custom/scripts/validate_and_rebuild.py --dry-run`
|
|
- Git-History: `git log --oneline custom/Espo/Custom/`
|
|
|
|
---
|
|
|
|
**Status:** ✅ Spezifikation vollständig (inkl. erweiterte Features)
|
|
**Nächster Schritt:** Phase 1 Implementierung starten
|
|
**Geschätzte Dauer:** 3-4 Wochen (alle Phasen inkl. erweiterter Features)
|
|
|
|
**Implementierungs-Reihenfolge:**
|
|
1. **Woche 1-2:** Basis-System (Phase 1-6) mit First-Read-Closes
|
|
2. **Woche 3:** Erweiterte Features (Phase 7.1-7.2: Filter + Team-Management)
|
|
3. **Woche 4:** Advanced Features (Phase 7.3-7.4: Task-Flow + KI-Katalog)
|
|
|
|
**Komplexität der neuen Features:**
|
|
- ⭐ Team-basierte Filter: 1 Tag (EINFACH)
|
|
- ⭐⭐ Team Add/Remove: 2-3 Tage (MITTEL)
|
|
- ⭐⭐⭐ Task/Call Integration mit Report Panels: 4-5 Tage (MITTEL-HOCH)
|
|
- Task-Entity erweitern: 1 Tag
|
|
- Auto-Approve Hook: 0.5 Tage
|
|
- Report Panels für 3 Parent-Types: 2-2.5 Tage
|
|
- Testing: 0.5 Tage
|
|
- ⭐⭐⭐⭐ KI-User Action Catalog: 5-7 Tage (HOCH)
|
|
|
|
---
|
|
|
|
*Ende des Pulssplans*
|