Update documentation: Add Hook development section and Custom Entities overview

This commit is contained in:
2026-03-09 23:12:30 +01:00
parent 2e9db78c6e
commit 63e3841f86
3 changed files with 906 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
# EspoCRM Best Practices & Entwicklungsrichtlinien
**Version:** 2.0
**Version:** 2.1
**Datum:** 9. März 2026
**Zielgruppe:** AI Code Agents & Entwickler
@@ -13,11 +13,12 @@
3. [Entity-Entwicklung](#entity-entwicklung)
4. [Relationship-Patterns](#relationship-patterns)
5. [API-Entwicklung](#api-entwicklung)
6. [Workflow-Management](#workflow-management)
7. [Testing & Validierung](#testing--validierung)
8. [Fehlerbehandlung](#fehlerbehandlung)
9. [Deployment-Prozess](#deployment-prozess)
10. [Troubleshooting](#troubleshooting)
6. [Hook-Entwicklung](#hook-entwicklung)
7. [Workflow-Management](#workflow-management)
8. [Testing & Validierung](#testing--validierung)
9. [Fehlerbehandlung](#fehlerbehandlung)
10. [Deployment-Prozess](#deployment-prozess)
11. [Troubleshooting](#troubleshooting)
---
@@ -67,6 +68,46 @@ client/custom/ # Frontend-Code
└── res/ # Resources
```
### Custom Entities Übersicht
**19 Custom Entities implementiert (Stand: März 2026):**
| Entity | Beschreibung | Hooks | Typ |
|--------|--------------|-------|-----|
| `CAdressen` | Adressen-Verwaltung | - | Base |
| `CAICollections` | AI-Dokumenten-Sammlungen | - | Base |
| `CAICollectionCDokumente` | Junction: Collections ↔ Dokumente | - | Junction |
| `CBankverbindungen` | Bankdaten (IBAN/BIC) | ✅ Validierung | Base |
| `CBeteiligte` | Beteiligte Personen | - | Base |
| `CCallQueues` | Call-Warteschlangen | - | Base |
| `CDokumente` | Dokumenten-Management | ✅ Hash-Berechnung | Base |
| `CKuendigung` | Kündigungen | - | Base |
| `CMietinkasso` | Mietinkasso-Fälle | - | Base |
| `CMietobjekt` | Mietobjekte | - | Base |
| `CPuls` | Posteingangs-System | ✅ Statistik | Base |
| `CPulsTeamZuordnung` | Puls-Team-Zuordnungen | - | Base |
| `CVMHBeteiligte` | VMH-spezifische Beteiligte | - | Base |
| `CVmhErstgespraech` | Erstgespräche | - | Base |
| `CVmhMietverhltnis` | Mietverhältnisse | - | Base |
| `CVmhRumungsklage` | Räumungsklagen | - | Base |
| `CVmhVermieter` | Vermieter | - | Base |
**Standard-Entities erweitert:**
- `Contact` - Erweiterterte Kontakt-Felder
- `Call` - Custom Call-Felder
- `User` - User-Erweiterungen
- `Meeting` - Meeting-Erweiterungen
- `Email` - E-Mail-Anpassungen
- `Task` - Task-Anpassungen
- `PhoneNumber` - Telefonnummern-Erweiterungen
- `Team` - Team-Anpassungen
- `BpmnUserTask` - Workflow-Task-Erweiterungen
**Implementierte Hooks:**
1. **CBankverbindungen/BankdatenValidation** - IBAN/BIC-Validierung mit Modulo-97
2. **CDokumente/CDokumente** - MD5/SHA256-Hash-Berechnung für Uploads
3. **CPuls/UpdateTeamStats** - Automatische Statistik-Berechnung
---
## Architektur-Prinzipien
@@ -123,7 +164,7 @@ client/custom/ # Frontend-Code
- ✅ Folge PSR-12 Coding Standard
**DON'T:**
- ❌ Keine komplexe Logik in Hooks
- ❌ Keine komplexe Business-Logic in Hooks (nutze Services)
- ❌ Keine direkten SQL-Queries (nutze EntityManager)
- ❌ Keine hard-coded Werte (nutze Config)
- ❌ Keine redundanten Includes
@@ -669,6 +710,523 @@ curl -X GET "https://crm.example.com/api/v1/CMyEntity" \
---
## Hook-Entwicklung
### Überblick
**Hooks** sind Event-Handler, die bei Entity-Lifecycle-Events ausgeführt werden. Sie ermöglichen automatische Validierung, Berechnung und Synchronisation ohne Frontend-Änderungen.
**Verzeichnis:** `custom/Espo/Custom/Hooks/{EntityName}/`
**Hook-Typen (EspoCRM 9.x Interface-basiert):**
| Interface | Trigger | Verwendung |
|-----------|---------|------------|
| `BeforeSave` | Vor dem Speichern | Validierung, Feld-Berechnung, Normalisierung |
| `AfterSave` | Nach dem Speichern | Notifications, externe API-Calls, Statistik-Updates |
| `BeforeRemove` | Vor dem Löschen | Validierung, Cascade-Prüfungen |
| `AfterRemove` | Nach dem Löschen | Cleanup, externe System-Updates |
| `AfterRelate` | Nach Relationship-Link | Statistik-Updates, Synchronisation |
| `AfterUnrelate` | Nach Relationship-Unlink | Statistik-Updates, Cleanup |
### Hook-Pattern (EspoCRM 9.x)
**Moderne Interface-basierte Hooks (PHP 8.2+):**
```php
<?php
namespace Espo\Custom\Hooks\{EntityName};
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Core\Hook\Hook\BeforeSave;
class MyHook implements BeforeSave
{
public function __construct(
private \Espo\ORM\EntityManager $entityManager,
private \Espo\Core\Utils\Config $config
) {}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
// Hook-Logik hier
}
}
```
**Legacy Hooks (EspoCRM < 7.0, noch unterstützt):**
```php
<?php
namespace Espo\Custom\Hooks\{EntityName};
use Espo\ORM\Entity;
class MyHook extends \Espo\Core\Hooks\Base
{
public function beforeSave(Entity $entity, array $options = [])
{
// Hook-Logik hier
}
}
```
### Dependency Injection
**Moderne Hooks nutzen Constructor Injection:**
```php
public function __construct(
private \Espo\ORM\EntityManager $entityManager,
private \Espo\Core\Utils\Config $config,
private \Espo\Core\Utils\Language $language,
private \Espo\Core\ServiceFactory $serviceFactory,
private \Espo\Core\Mail\EmailSender $emailSender
) {}
```
**Verfügbare Services:**
- `EntityManager` - Datenbankzugriff
- `Config` - System-Konfiguration
- `Language` - i18n-Übersetzungen
- `ServiceFactory` - Service-Instanzen
- `EmailSender` - E-Mail-Versand
- `Acl` - ACL-Prüfungen
- `User` - Aktueller Benutzer
### Praxis-Beispiele aus dem Projekt
#### Beispiel 1: Daten-Validierung & Normalisierung (CBankverbindungen)
**Datei:** `custom/Espo/Custom/Hooks/CBankverbindungen/BankdatenValidation.php`
**Use Case:** IBAN/BIC-Validierung mit Modulo-97-Algorithmus
```php
<?php
namespace Espo\Custom\Hooks\CBankverbindungen;
use Espo\ORM\Entity;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Utils\Language;
class BankdatenValidation
{
public function __construct(
private Language $language
) {}
public function beforeSave(Entity $entity, array $options): void
{
// IBAN-Normalisierung und Validierung
$iban = $entity->get('iban');
if ($iban !== null && $iban !== '') {
// Normalisieren: Leerzeichen entfernen, Großbuchstaben
$ibanClean = strtoupper(str_replace(' ', '', $iban));
$entity->set('iban', $ibanClean);
// Mathematische IBAN-Prüfung mit Modulo-97
if (!$this->validateIban($ibanClean)) {
$message = $this->language->translateLabel(
'invalidIbanChecksum',
'messages',
'CBankverbindungen'
);
throw new BadRequest($message);
}
}
// BIC-Normalisierung und Validierung
$bic = $entity->get('bic');
if ($bic !== null && $bic !== '') {
$bicClean = strtoupper(str_replace(' ', '', $bic));
$entity->set('bic', $bicClean);
// BIC-Format: 8 oder 11 Zeichen
$bicLength = strlen($bicClean);
if ($bicLength !== 8 && $bicLength !== 11) {
$message = $this->language->translateLabel(
'invalidBicLength',
'messages',
'CBankverbindungen'
);
throw new BadRequest($message);
}
// BIC-Regex: AAAA BB CC DDD
if (!preg_match('/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/', $bicClean)) {
$message = $this->language->translateLabel(
'invalidBicFormat',
'messages',
'CBankverbindungen'
);
throw new BadRequest($message);
}
}
}
private function validateIban(string $iban): bool
{
if (strlen($iban) < 15) {
return false;
}
// IBAN umstellen: erste 4 Zeichen ans Ende
$rearranged = substr($iban, 4) . substr($iban, 0, 4);
// Buchstaben in Zahlen umwandeln (A=10, B=11, ..., Z=35)
$numeric = '';
for ($i = 0; $i < strlen($rearranged); $i++) {
$char = $rearranged[$i];
if (ctype_alpha($char)) {
$numeric .= (string)(ord($char) - ord('A') + 10);
} else {
$numeric .= $char;
}
}
// Modulo-97-Prüfung: Ergebnis muss 1 sein
return $this->bcmod($numeric, '97') === '1';
}
private function bcmod(string $number, string $modulus): string
{
// Modulo für sehr große Zahlen in Schritten
$take = 9;
$mod = '';
do {
$a = (int)($mod . substr($number, 0, $take));
$number = substr($number, $take);
$mod = (string)($a % (int)$modulus);
} while (strlen($number) > 0);
return $mod;
}
}
```
**i18n-Messages (erforderlich):**
```json
// custom/Espo/Custom/Resources/i18n/de_DE/CBankverbindungen.json
{
"messages": {
"invalidIbanChecksum": "IBAN-Prüfsumme ungültig (Modulo-97-Fehler)",
"invalidBicLength": "BIC muss 8 oder 11 Zeichen lang sein",
"invalidBicFormat": "BIC-Format ungültig (erwarte: AAAAAA BB CC DDD)"
}
}
```
**Best Practice:**
- ✅ Normalisierung VOR Validierung
- ✅ i18n für Fehlermeldungen
- ✅ BadRequest mit Fehlertext werfen
- ✅ Mathematisch korrekte Algorithmen (Modulo-97)
---
#### Beispiel 2: Hash-Berechnung (CDokumente)
**Datei:** `custom/Espo/Custom/Hooks/CDokumente/CDokumente.php`
**Use Case:** Automatische MD5/SHA256-Hash-Berechnung für Datei-Uploads
```php
<?php
namespace Espo\Custom\Hooks\CDokumente;
use Espo\ORM\Entity;
class CDokumente extends \Espo\Core\Hooks\Base
{
public function beforeSave(Entity $entity, array $options = [])
{
$dokument = $entity->get('dokument');
if (!$dokument) {
return;
}
// Attachment laden
if (is_object($dokument)) {
$attachment = $dokument;
} else {
$attachment = $this->getEntityManager()
->getEntity('Attachment', $dokument);
}
if (!$attachment) {
return;
}
// Dateipfad prüfen
$filePath = 'data/upload/' . $attachment->get('id');
if (!file_exists($filePath)) {
return;
}
// Hash-Berechnung
$newMd5 = hash_file('md5', $filePath);
$newSha256 = hash_file('sha256', $filePath);
$entity->set('md5sum', $newMd5);
$entity->set('sha256', $newSha256);
// Status-Erkennung
if ($entity->isNew()) {
$entity->set('fileStatus', 'new');
} else {
$oldMd5 = $entity->getFetched('md5sum');
$oldSha256 = $entity->getFetched('sha256');
if ($oldMd5 !== $newMd5 || $oldSha256 !== $newSha256) {
$entity->set('fileStatus', 'changed');
} else {
$entity->set('fileStatus', 'synced');
}
}
}
}
```
**Hinweis:**
EspoCRM markiert Datei-Uploads nicht als Feldänderung (`isAttributeChanged('dokument')` = false). Daher läuft der Hook bei jedem Save mit Dokument-Feld.
**Best Practice:**
- ✅ File-Existence-Check vor Hash-Berechnung
- ✅ Unterstütze sowohl Object als auch ID
- ✅ Nutze `isNew()` für Status-Logik
- ✅ Dokumentiere API-Limitationen als Kommentar
---
#### Beispiel 3: Statistik-Berechnung (CPuls)
**Datei:** `custom/Espo/Custom/Hooks/CPuls/UpdateTeamStats.php`
**Use Case:** Automatische Berechnung von Zählern für verwandte Entities
```php
<?php
namespace Espo\Custom\Hooks\CPuls;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
use Espo\Core\Hook\Hook\BeforeSave;
class UpdateTeamStats implements BeforeSave
{
public function __construct(
private \Espo\ORM\EntityManager $entityManager
) {}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
// Dokumente zählen
if ($entity->isNew() || $entity->isAttributeChanged('id')) {
$dokumenteCount = $this->entityManager
->getRDBRepository('CDokumente')
->where(['pulsId' => $entity->getId()])
->count();
$entity->set('anzahlDokumente', $dokumenteCount);
}
// Team-Zuordnungen analysieren
$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);
}
}
```
**Best Practice:**
- ✅ Moderne Interface-basierte Hook-Klasse
- ✅ Constructor Injection für EntityManager
- ✅ Private Typed Properties (PHP 8.2+)
- ✅ Bedingte Berechnung (`isNew()`, `isAttributeChanged()`)
- ✅ Repository queries statt direktes SQL
---
### Best Practices für Hooks
#### ✅ DO
1. **Nutze Interface-basierte Hooks (EspoCRM 9.x)**
```php
class MyHook implements BeforeSave { }
```
2. **Constructor Injection für Dependencies**
```php
public function __construct(
private EntityManager $entityManager
) {}
```
3. **Validierung in beforeSave, Notifications in afterSave**
- `beforeSave`: Synchron, blockiert Transaction
- `afterSave`: Transaction bereits committed
4. **Exception werfen bei Validierungsfehlern**
```php
throw new BadRequest('Error message');
throw new Forbidden('Access denied');
```
5. **i18n für Fehlermeldungen**
```php
$this->language->translateLabel('key', 'messages', 'EntityName');
```
6. **Performance-Optimierung mit Conditions**
```php
if ($entity->isNew() || $entity->isAttributeChanged('field')) {
// Nur bei Änderung ausführen
}
```
#### ❌ DON'T
1. **Keine komplexe Business-Logic in Hooks**
→ Nutze Services stattdessen
2. **Keine direkten SQL-Queries**
→ Nutze EntityManager/Repositories
3. **Keine externe API-Calls in beforeSave**
→ Kann Transaction blockieren, nutze afterSave oder Queue
4. **Keine Circular Dependencies**
→ Hook A speichert Entity B, Hook B speichert Entity A = Endlosschleife
5. **Keine Hooks für UI-Logic**
→ Nutze Frontend-Controller
### Hook-Reihenfolge
**Entity Save Lifecycle:**
```
1. beforeSave Hook
2. Entity Validation
3. Database Transaction START
4. INSERT/UPDATE Query
5. Transaction COMMIT
6. afterSave Hook
7. Stream/Notification
```
**Wichtig:**
- `beforeSave`: Änderungen im Entity werden gespeichert
- `afterSave`: Entity ist bereits committed, Änderungen erfordern separates `saveEntity()`
### Debugging Hooks
**Log-Output:**
```php
$GLOBALS['log']->debug('MyHook: ' . json_encode([
'entity' => $entity->getEntityType(),
'id' => $entity->getId(),
'isNew' => $entity->isNew(),
'changed' => $entity->get('field')
]));
```
**Log-File:**
```bash
tail -f data/logs/espo-$(date +%Y-%m-%d).log | grep MyHook
```
**Fehlersuche:**
1. **Hook wird nicht ausgeführt**
- Clear Cache: `php clear_cache.php`
- Rebuild: `php rebuild.php`
- Prüfe Namespace/Klassennamen
2. **Exception in Hook**
- Prüfe Log: `data/logs/espo-{date}.log`
- Prüfe Type Hints (PHP 8.2 strict types)
- Validiere Constructor Injection
3. **Hook läuft mehrfach**
- Prüfe auf Circular Dependencies
- Nutze Conditions (`isAttributeChanged()`)
### Troubleshooting
**Problem: Hook läuft nicht**
```bash
# Cache clearen
php clear_cache.php
# Rebuild
php rebuild.php
# Hook-Datei prüfen
php -l custom/Espo/Custom/Hooks/{Entity}/{HookName}.php
```
**Problem: Circular Dependency**
```php
// ❌ FALSCH: Endlosschleife
class HookA implements BeforeSave {
public function beforeSave(Entity $entity, SaveOptions $options): void {
$entityB = $this->entityManager->getEntity('EntityB', 'id');
$entityB->set('field', 'value');
$this->entityManager->saveEntity($entityB); // triggert HookB
}
}
class HookB implements BeforeSave {
public function beforeSave(Entity $entity, SaveOptions $options): void {
$entityA = $this->entityManager->getEntity('EntityA', 'id');
$entityA->set('field', 'value');
$this->entityManager->saveEntity($entityA); // triggert HookA → LOOP!
}
}
// ✅ RICHTIG: Mit Flag
class HookA implements BeforeSave {
public function beforeSave(Entity $entity, SaveOptions $options): void {
if ($options->get('skipHooks')) {
return;
}
$entityB = $this->entityManager->getEntity('EntityB', 'id');
$entityB->set('field', 'value');
$this->entityManager->saveEntity($entityB, [
'skipHooks' => true
]);
}
}
```
---
## Workflow-Management
### Workflow-Dateien
@@ -991,6 +1549,59 @@ docker exec espocrm php -l custom/Espo/Custom/Controllers/MyController.php
- [ ] Syntax korrekt?
- [ ] Rebuild durchgeführt?
### Bekannte i18n-Warnungen (nicht kritisch)
**Stand: März 2026**
Die folgenden i18n-Link-Labels fehlen aktuell (funktional keine Auswirkung):
```
⚠ CDokumente (en_US): Link 'cAICollections' fehlt in i18n
⚠ CAICollections (de_DE): Link 'meetings' fehlt in i18n
⚠ CAICollections (de_DE): Link 'cDokumente' fehlt in i18n
⚠ CAICollections (en_US): Link 'cDokumente' fehlt in i18n
```
**Behebung (optional):**
**Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/CDokumente.json`
```json
{
"links": {
"cAICollections": "AI Collections"
}
}
```
**Datei:** `custom/Espo/Custom/Resources/i18n/en_US/CDokumente.json`
```json
{
"links": {
"cAICollections": "AI Collections"
}
}
```
**Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/CAICollections.json`
```json
{
"links": {
"cDokumente": "Dokumente",
"meetings": "Meetings"
}
}
```
**Datei:** `custom/Espo/Custom/Resources/i18n/en_US/CAICollections.json`
```json
{
"links": {
"cDokumente": "Documents",
"meetings": "Meetings"
}
}
```
---
## Projekt-spezifische Entities