# 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*
