# EspoCRM Best Practices & Entwicklungsrichtlinien **Version:** 2.3 **Datum:** 11. März 2026 **Zielgruppe:** AI Code Agents & Entwickler --- ## 🔄 Letzte Änderungen (v2.3 - 11. März 2026) **Neue Features:** - ✅ **Junction Table UI-Pattern**: columnAttributeMap + notStorable für UI-Anzeige von Junction-Spalten - ✅ **Dokumenten-Propagierung**: Hook-Pattern für automatische Verknüpfung zwischen hierarchischen Entities - ✅ **Loop-Schutz**: Statisches Processing-Array Pattern für rekursive Hooks - ✅ **Troubleshooting**: Vergessene Indizes auf gelöschte Felder (häufiger Rebuild-Fehler) **Dokumentierte Real-World Implementierung:** - CAdvowareAkten/CAIKnowledge Junction Tables mit additionalColumns (hnr, syncstatus, lastSync) - Propagierungs-Hooks: Räumungsklage ↔ AdvowareAkten ↔ AIKnowledge - Sync-Status-Management mit globalen und Junction-level Status-Feldern - Hook-Chain für automatische Status-Propagierung bei Dokumentänderungen --- ## 🔄 Letzte Änderungen (v2.2 - 10. März 2026) **Kritische Erkenntnisse:** - ✅ **Service-Klassen sind PFLICHT**: Neue Section über erforderliche Service-Klassen - ✅ **InjectableFactory-Fehler**: Detaillierter Troubleshooting-Guide hinzugefügt - ✅ **validate_and_rebuild.py v2.0**: Erweiterte Log-Prüfung und CRUD-Tests - ✅ **Real-World-Beispiel**: CAICollection/CAdvowareAkten Fehlerfall dokumentiert **Tools:** - 🆕 Minimierter/Verbose Output-Modus - 🆕 Log-Prüfung nach jedem API-Request - 🆕 CRUD-Tests für alle Entities - 🆕 KI-freundliches JSON-Feedback --- --- ## 📋 Inhaltsverzeichnis 1. [Projekt-Übersicht](#projekt-übersicht) 2. [Architektur-Prinzipien](#architektur-prinzipien) 3. [Entity-Entwicklung](#entity-entwicklung) 4. [Relationship-Patterns](#relationship-patterns) 5. [API-Entwicklung](#api-entwicklung) 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) --- ## Projekt-Übersicht ### System-Architektur ``` EspoCRM 9.3.2 ├── PHP 8.2.30 ├── MariaDB 12.2.2 ├── Docker Container: espocrm, espocrm-db └── Workspace: /var/lib/docker/volumes/vmh-espocrm_espocrm/_data ``` ### Verzeichnisstruktur ``` custom/ ├── Espo/Custom/ # Backend-Code │ ├── Controllers/ # REST API Endpoints │ ├── Services/ # Business Logic │ ├── Repositories/ # Data Access Layer │ ├── Hooks/ # Entity Lifecycle Hooks │ └── Resources/ │ ├── metadata/ # Entity & Field Definitionen │ │ ├── entityDefs/ # Entity-Konfiguration │ │ ├── clientDefs/ # Frontend-Konfiguration │ │ ├── scopes/ # Entity-Scopes │ │ └── formula/ # Formula Scripts │ ├── layouts/ # UI-Layouts │ └── i18n/ # Übersetzungen (de_DE, en_US) ├── scripts/ # Entwicklungs-Tools │ ├── validate_and_rebuild.py # Haupt-Validierungs-Tool │ ├── e2e_tests.py # End-to-End Tests │ ├── ki_project_overview.py # Projekt-Analyse für AI │ └── junctiontabletests/ # Junction Table Tests ├── docs/ # Dokumentation (NEU) │ ├── ESPOCRM_BEST_PRACTICES.md # Dieses Dokument │ ├── tools/ # Tool-Dokumentation │ └── workflows/ # Workflow-Dokumentation └── workflows/ # Workflow JSON-Definitions client/custom/ # Frontend-Code ├── src/ # JavaScript Modules ├── css/ # Custom Styles └── 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 ### 1. Separation of Concerns **EspoCRM = Data Layer** - Speichert Entities - Stellt UI bereit - Validiert Daten - Bietet REST API **Middleware = Business Logic** - KI-Analyse - Team-Zuweisung - Komplexe Workflows - Externe Integrationen ### 2. Drei-Schichten-Architektur ``` ┌─────────────────────────────────────────┐ │ FRONTEND (clientDefs, Layouts) │ │ • User Interface │ │ • JavaScript Actions │ └────────────────┬────────────────────────┘ │ AJAX/REST ┌────────────────▼────────────────────────┐ │ CONTROLLER (Controllers/) │ │ • Request Validation │ │ • ACL Checks │ └────────────────┬────────────────────────┘ │ Service Call ┌────────────────▼────────────────────────┐ │ SERVICE (Services/) │ │ • Business Logic │ │ • Entity Manager │ └────────────────┬────────────────────────┘ │ Repository ┌────────────────▼────────────────────────┐ │ REPOSITORY (Repositories/) │ │ • Data Access │ │ • Relationships │ └─────────────────────────────────────────┘ ``` ### 3. Clean Code Principles **DO:** - ✅ Nutze sprechende Variablennamen - ✅ Schreibe kleine, fokussierte Funktionen - ✅ Kommentiere komplexe Business-Logik - ✅ Verwende Type Hints (PHP 8.2+) - ✅ Folge PSR-12 Coding Standard **DON'T:** - ❌ Keine komplexe Business-Logic in Hooks (nutze Services) - ❌ Keine direkten SQL-Queries (nutze EntityManager) - ❌ Keine hard-coded Werte (nutze Config) - ❌ Keine redundanten Includes - ❌ Keine ungenutzten Imports --- ## Entity-Entwicklung ### Entity-Naming Convention **Pattern:** `C{EntityName}` für Custom Entities **Beispiele:** - `CMietobjekt` - Mietobjekte - `CVmhMietverhltnis` - Mietverhältnisse (VMH = Vermieter Helden) - `CKuendigung` - Kündigungen - `CAICollections` - AI Collections ### Entity Definition Template **Datei:** `custom/Espo/Custom/Resources/metadata/entityDefs/{EntityName}.json` ```json { "fields": { "name": { "type": "varchar", "required": true, "maxLength": 255, "trim": true, "isCustom": true, "tooltip": true }, "status": { "type": "enum", "options": ["Neu", "In Bearbeitung", "Abgeschlossen"], "default": "Neu", "required": true, "isCustom": true, "style": { "Neu": "primary", "In Bearbeitung": "warning", "Abgeschlossen": "success" } }, "description": { "type": "text", "rows": 10, "isCustom": true, "tooltip": true }, "amount": { "type": "currency", "isCustom": true, "audited": true }, "dueDate": { "type": "date", "isCustom": true, "audited": true } }, "links": { "parent": { "type": "belongsToParent", "entityList": ["CVmhRumungsklage", "CMietinkasso"] }, "createdBy": { "type": "belongsTo", "entity": "User" }, "modifiedBy": { "type": "belongsTo", "entity": "User" } } } ``` ### Scope Definition **Datei:** `custom/Espo/Custom/Resources/metadata/scopes/{EntityName}.json` ```json { "entity": true, "type": "Base", "module": "Custom", "object": true, "isCustom": true, "tab": true, "acl": true, "stream": true, "disabled": false, "customizable": true, "importable": true, "notifications": true, "calendar": false } ``` **Wichtige Flags:** - `tab: true` - Zeigt Entity in Navigation - `acl: true` - ACL-System aktiv - `stream: true` - Stream/Activity Feed - `calendar: true` - Für Entities mit Datum-Feldern ### Service-Klassen (KRITISCH!) **⚠️ PFLICHT:** Jede Custom Entity MUSS eine Service-Klasse haben! **Problem:** Ohne Service-Klasse gibt EspoCRM beim Zugriff folgenden Fehler: ``` CRITICAL: InjectableFactory: Class 'Espo\Custom\Services\{EntityName}' does not exist. ``` **Lösung:** Erstelle für jede Entity eine Service-Klasse: **Datei:** `custom/Espo/Custom/Services/{EntityName}.php` ```php getAcl()->checkEntityEdit($this->entityType)) { throw new Forbidden(); } // Load Entity $entity = $this->getEntityManager()->getEntity($this->entityType, $id); if (!$entity) { throw new NotFound(); } // Business Logic hier $entity->set('status', 'Processed'); $this->getEntityManager()->saveEntity($entity); return [ 'success' => true, 'id' => $entity->getId() ]; } } ``` **Best Practice:** 1. ✅ Erstelle Service-Klasse SOFORT bei Entity-Erstellung 2. ✅ Auch wenn initial leer, erstelle sie trotzdem 3. ✅ Nutze Service für Business Logic statt Hooks 4. ✅ Verwende Type Hints für bessere IDE-Unterstützung 5. ✅ Dokumentiere Custom-Methoden mit DocBlocks --- ### i18n (Internationalisierung) **KRITISCH:** Immer BEIDE Sprachen pflegen! **Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/{EntityName}.json` ```json { "labels": { "Create {EntityName}": "{EntityName} erstellen", "{EntityName}": "{EntityName}", "name": "Name", "status": "Status", "description": "Beschreibung" }, "fields": { "name": "Name", "status": "Status", "description": "Beschreibung", "amount": "Betrag", "dueDate": "Fälligkeitsdatum" }, "links": { "parent": "Übergeordnet", "relatedEntity": "Verknüpfte Entity" }, "options": { "status": { "Neu": "Neu", "In Bearbeitung": "In Bearbeitung", "Abgeschlossen": "Abgeschlossen" } }, "tooltips": { "name": "Eindeutiger Name des Datensatzes", "description": "Detaillierte Beschreibung" } } ``` **Datei:** `custom/Espo/Custom/Resources/i18n/en_US/{EntityName}.json` ```json { "labels": { "Create {EntityName}": "Create {EntityName}", "{EntityName}": "{EntityName}" }, "fields": { "name": "Name", "status": "Status", "description": "Description", "amount": "Amount", "dueDate": "Due Date" }, "links": { "parent": "Parent", "relatedEntity": "Related Entity" }, "options": { "status": { "Neu": "New", "In Bearbeitung": "In Progress", "Abgeschlossen": "Completed" } } } ``` --- ## Relationship-Patterns ### 1. One-to-Many (hasMany / belongsTo) **Beispiel:** Ein Mietobjekt hat viele Mietverhältnisse **Parent Entity (CMietobjekt):** ```json { "links": { "mietverhltnisse": { "type": "hasMany", "entity": "CVmhMietverhltnis", "foreign": "mietobjekt" } } } ``` **Child Entity (CVmhMietverhltnis):** ```json { "fields": { "mietobjektId": { "type": "varchar", "len": 17 }, "mietobjektName": { "type": "varchar" } }, "links": { "mietobjekt": { "type": "belongsTo", "entity": "CMietobjekt", "foreign": "mietverhltnisse" } } } ``` ### 2. Many-to-Many (hasMany mit relationName) **Beispiel:** Dokumente ↔ AI Collections **Entity 1 (CDokumente):** ```json { "links": { "cAICollections": { "type": "hasMany", "entity": "CAICollections", "foreign": "cDokumente", "relationName": "cAICollectionCDokumente" } } } ``` **Entity 2 (CAICollections):** ```json { "links": { "cDokumente": { "type": "hasMany", "entity": "CDokumente", "foreign": "cAICollections", "relationName": "cAICollectionCDokumente" } } } ``` **Wichtig:** `relationName` muss identisch sein! ### 3. Many-to-Many mit additionalColumns (Junction Entity) **Seit EspoCRM 6.0:** Junction-Tabellen werden automatisch als Entities verfügbar! **Entity Definition:** ```json { "links": { "cDokumente": { "type": "hasMany", "entity": "CDokumente", "foreign": "cAICollections", "relationName": "cAICollectionCDokumente", "additionalColumns": { "syncId": { "type": "varchar", "len": 255 } } } } } ``` **Junction Entity (CAICollectionCDokumente):** **entityDefs/CAICollectionCDokumente.json:** ```json { "fields": { "id": { "type": "id", "dbType": "bigint", "autoincrement": true }, "cAICollections": { "type": "link" }, "cAICollectionsId": { "type": "varchar", "len": 17, "index": true }, "cDokumente": { "type": "link" }, "cDokumenteId": { "type": "varchar", "len": 17, "index": true }, "syncId": { "type": "varchar", "len": 255, "isCustom": true }, "deleted": { "type": "bool", "default": false } }, "links": { "cAICollections": { "type": "belongsTo", "entity": "CAICollections" }, "cDokumente": { "type": "belongsTo", "entity": "CDokumente" } } } ``` **scopes/CAICollectionCDokumente.json:** ```json { "entity": true, "type": "Base", "module": "Custom", "object": true, "isCustom": true, "tab": false, "acl": true, "disabled": false } ``` **Controller & Service:** ```php getParsedBody(); $id = $data->id ?? null; if (!$id) { throw new BadRequest('ID is required'); } $result = $this->getRecordService()->doSomething($id, $data); return [ 'success' => true, 'data' => $result ]; } /** * Custom GET Action: GET /api/v1/CMyEntity/{id}/customData */ public function getActionCustomData(Request $request): array { $id = $request->getRouteParam('id'); $data = $this->getRecordService()->getCustomData($id); return [ 'data' => $data ]; } } ``` **2. Service Logic:** **Datei:** `custom/Espo/Custom/Services/{EntityName}.php` ```php getAcl()->checkEntityEdit($this->entityType)) { throw new Forbidden(); } // Load Entity $entity = $this->getEntityManager()->getEntity($this->entityType, $id); if (!$entity) { throw new NotFound(); } // Business Logic $entity->set('status', 'In Bearbeitung'); $this->getEntityManager()->saveEntity($entity); // Return Result return [ 'id' => $entity->getId(), 'status' => $entity->get('status') ]; } public function getCustomData(string $id): array { $entity = $this->getEntityManager()->getEntity($this->entityType, $id); if (!$entity) { throw new NotFound(); } // Complex data aggregation $relatedData = $this->getRelatedData($entity); return [ 'entity' => $entity->getValueMap(), 'related' => $relatedData ]; } } ``` ### API Authentication **API Key Header:** ```bash curl -X GET "https://crm.example.com/api/v1/CMyEntity" \ -H "X-Api-Key: your-api-key-here" ``` **Test API Keys:** - `marvin`: `e53def10eea27b92a6cd00f40a3e09a4` - `dev-test`: `2b0747ca34d15032aa233ae043cc61bc` --- ## 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 getId() . '-' . $foreignEntity->getId() . '-relate'; if (isset(self::$processing[$key])) { return; // Bereits in Bearbeitung } self::$processing[$key] = true; try { // Hole verbundene AdvowareAkten $advowareAkten = $this->entityManager ->getRDBRepository('CVmhRumungsklage') ->getRelation($entity, 'advowareAkten') ->findOne(); if ($advowareAkten) { $this->relateDocument($advowareAkten, 'dokumentes', $foreignEntity); } // Hole verbundene AIKnowledge $aIKnowledge = $this->entityManager ->getRDBRepository('CVmhRumungsklage') ->getRelation($entity, 'aIKnowledge') ->findOne(); if ($aIKnowledge) { $this->relateDocument($aIKnowledge, 'dokumentes', $foreignEntity); } } catch (\Exception $e) { $GLOBALS['log']->error('PropagateDocuments Error: ' . $e->getMessage()); } finally { unset(self::$processing[$key]); // Cleanup } } public function afterUnrelate( Entity $entity, string $relationName, Entity $foreignEntity, \Espo\ORM\Repository\Option\UnrelateOptions $options ): void { // Analog zu afterRelate, aber mit unrelate() } private function relateDocument(Entity $parent, string $relation, Entity $doc): void { $repository = $this->entityManager->getRDBRepository($parent->getEntityType()); $relation = $repository->getRelation($parent, $relation); // Prüfe ob bereits verknüpft (vermeidet Duplikate) $isRelated = $relation->where(['id' => $doc->getId()])->findOne(); if (!$isRelated) { $relation->relate($doc); } } } ``` **Propagierungs-Hierarchie:** ``` ┌─────────────────────┐ │ Räumungsklage │ │ Mietinkasso │ └──────────┬──────────┘ │ ┌──────┴──────┐ ↓ ↓ ┌──────────┐ ┌──────────┐ │AdvowareA.│ │AIKnowled.│ └────┬─────┘ └────┬─────┘ │ │ └──────┬──────┘ ↓ ┌──────────┐ │ Dokument │ └──────────┘ ``` **Down-Propagierung (Räumungsklage → unten):** - Hook in Räumungsklage/Mietinkasso - Bei Dokumenten-Link → propagiere zu AdvowareAkten + AIKnowledge - Deren Hooks versuchen zurück zu propagieren → blockiert durch Loop-Schutz **Up-Propagierung (AdvowareAkten → oben):** - Hook in AdvowareAkten/AIKnowledge - Bei Dokumenten-Link → propagiere zu Räumungsklage/Mietinkasso - Deren Hooks propagieren zu anderen Kind-Entities - Loop-Schutz verhindert Rück-Propagierung **Loop-Schutz Mechanismus:** 1. **Statisches Array**: `private static array $processing = []` 2. **Eindeutiger Key**: `{EntityID}-{DokumentID}-{Aktion}` 3. **Check vor Ausführung**: `if (isset(self::$processing[$key])) return;` 4. **Set bei Start**: `self::$processing[$key] = true;` 5. **Cleanup**: `finally { unset(self::$processing[$key]); }` **Vorteile:** - Verhindert Endlos-Rekursion - Ermöglicht parallele Verarbeitung verschiedener Dokumente - Automatisches Cleanup auch bei Exceptions - Key-basiert: Verschiedene Operations können gleichzeitig laufen ### 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 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 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 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 **Verzeichnis:** `custom/workflows/` **Format:** JSON (Simple Workflow oder BPM Flowchart) ### Simple Workflow Beispiel ```json { "type": "simple", "name": "auto-assign-new-entity", "entity_type": "CMyEntity", "trigger_type": "afterRecordCreated", "is_active": true, "description": "Auto-assign new records to team", "conditions_all": [ { "type": "isEmpty", "attribute": "assignedUserId" } ], "actions": [ { "type": "applyAssignmentRule", "targetTeamId": "team-id-here" }, { "type": "sendEmail", "to": "assignedUser", "emailTemplateId": "template-id" } ] } ``` ### Workflow Import/Export ```bash # Alle Workflows exportieren php custom/scripts/workflow_manager.php export # Workflow importieren php custom/scripts/workflow_manager.php import custom/workflows/my-workflow.json # Workflows auflisten php custom/scripts/workflow_manager.php list ``` --- ## Testing & Validierung ### Validierungs-Tool v2.0 (Erweitert) **Haupt-Tool:** `custom/scripts/validate_and_rebuild.py` #### Verwendung ```bash # Standard: Minimaler Output, vollständige Tests python3 custom/scripts/validate_and_rebuild.py # Verbose: Detaillierte Ausgabe für Debugging python3 custom/scripts/validate_and_rebuild.py -v # Nur Validierung (kein Rebuild) python3 custom/scripts/validate_and_rebuild.py --dry-run # Ohne Entity CRUD-Tests python3 custom/scripts/validate_and_rebuild.py --skip-tests # Mit Custom-Credentials python3 custom/scripts/validate_and_rebuild.py --username admin --password secret # Mit Custom Base-URL python3 custom/scripts/validate_and_rebuild.py --base-url http://my-espo.local ``` #### Was das Tool prüft **Phase 1: Statische Validierung** 1. ✅ JSON-Syntax aller Custom-Dateien 2. ✅ Relationship-Konsistenz (bidirektionale Links) 3. ✅ Erforderliche Dateien (scopes, i18n) 4. ✅ Dateirechte (www-data:www-data) + Auto-Fix 5. ✅ PHP-Syntax (php -l) **Phase 2: Rebuild mit Log-Prüfung** 6. ✅ Cache-Clearing 7. ✅ EspoCRM Rebuild 8. ✅ **Log-Prüfung direkt nach Rebuild** (mit präzisen Zeitstempeln) **Phase 3: Live Entity-Tests** (wenn nicht übersprungen) 9. ✅ **CREATE** - Eintrag erstellen + Log-Check 10. ✅ **READ** - Eintrag lesen + Log-Check 11. ✅ **UPDATE** - Eintrag aktualisieren + Log-Check 12. ✅ **LIST** - Liste abrufen + Log-Check 13. ✅ **DELETE** - Eintrag löschen + Log-Check 14. ✅ Automatisches Cleanup aller Test-Records #### Neue Features (v2.0) **1. Log-Integration:** - Nach **jedem** API-Request werden Logs geprüft - Präzise Zeitstempel (nur Logs seit Request-Start) - Test bricht ab bei Fehler in Logs - Filtert bekannte unwichtige Meldungen **2. Intelligenter Output:** - **Standard-Modus:** Nur Ergebnisse (✓/✗) - **Verbose-Modus (`-v`):** Detaillierte Informationen, Timing, Debugging **3. CRUD-Tests für alle Entities:** - Testet jede Custom-Entity einzeln - Validiert kompletten Lifecycle - Prüft API-Responses - Detektiert Service-Klassen-Fehler **4. KI-freundliches Feedback:** ```json { "status": "success", "summary": { "errors": 0, "warnings": 1, "entities_checked": 28 }, "errors": [], "warnings": ["..."], "recommendations": [ "Fehlende Service-Klassen - erstelle für jede Entity eine Service-Klasse", "Unvollständige Übersetzungen - füge fehlende i18n-Dateien hinzu" ] } ``` **5. Fehler-Abbruch bei Log-Errors:** - Script bricht sofort ab wenn Rebuild Fehler in Logs produziert - Zeigt relevante Fehlermeldungen an - Keine weiteren Tests bei kritischen Fehlern #### Typischer Workflow ```bash # 1. Nach Code-Änderungen: Quick Check python3 custom/scripts/validate_and_rebuild.py --dry-run # 2. Vor Deployment: Vollständiger Test python3 custom/scripts/validate_and_rebuild.py -v # 3. Rebuild ohne Tests (schneller) python3 custom/scripts/validate_and_rebuild.py --skip-tests ``` **Bei Fehlern:** Das Tool zeigt automatisch: - Fehlerlog-Analyse - Betroffene Dateien - Konkrete Fehlermeldungen - Empfohlene Fixes ### End-to-End Tests **Tool:** `custom/scripts/e2e_tests.py` ```bash # E2E Tests ausführen python3 custom/scripts/e2e_tests.py ``` **Tests:** - CRUD für alle Custom Entities - Relationship-Verknüpfungen - ACL-Prüfungen ### Manuelle Tests **Checkliste:** - [ ] Entity in UI sichtbar? - [ ] Felder editierbar? - [ ] Relationships funktionieren? - [ ] Formulas triggern korrekt? - [ ] Workflows aktiv? - [ ] API-Endpoints erreichbar? - [ ] ACL-Regeln greifen? --- ## Fehlerbehandlung ### Log-Files **Verzeichnis:** `data/logs/` **Haupt-Logfile:** `espo-{YYYY-MM-DD}.log` ```bash # Letzte Fehler anzeigen tail -50 data/logs/espo-$(date +%Y-%m-%d).log | grep -i error # Live-Monitoring tail -f data/logs/espo-$(date +%Y-%m-%d).log ``` ### Häufige Fehler #### 1. Layout-Fehler: "false" statt "{}" **Problem:** EspoCRM 7.x+ erfordert `{}` statt `false` als Platzhalter **Falsch:** ```json { "rows": [ [ {"name": "field1"}, false ] ] } ``` **Richtig:** ```json { "rows": [ [ {"name": "field1"}, {} ] ] } ``` #### 2. Relationship nicht bidirektional **Problem:** `foreign` zeigt nicht zurück **Falsch:** ```json // Entity A "links": { "entityB": { "type": "hasMany", "entity": "EntityB", "foreign": "wrongName" // ❌ } } // Entity B "links": { "entityA": { "type": "belongsTo", "entity": "EntityA", "foreign": "entityB" } } ``` **Richtig:** ```json // Entity A "links": { "entityB": { "type": "hasMany", "entity": "EntityB", "foreign": "entityA" // ✅ Zeigt auf Link-Namen in B } } // Entity B "links": { "entityA": { "type": "belongsTo", "entity": "EntityA", "foreign": "entityB" // ✅ Zeigt auf Link-Namen in A } } ``` #### 3. i18n fehlt für en_US **Problem:** Nur de_DE vorhanden, en_US fehlt **Lösung:** IMMER beide Sprachen pflegen! en_US ist Fallback. #### 4. Dateirechte falsch **Problem:** Files gehören root statt www-data **Lösung:** Automatisch via validate_and_rebuild.py oder manuell: ```bash sudo chown -R www-data:www-data custom/ sudo find custom/ -type f -exec chmod 664 {} \; sudo find custom/ -type d -exec chmod 775 {} \; ``` #### 5. ACL: 403 Forbidden **Problem:** Role hat keine Rechte auf Entity **Lösung:** ACL in Admin UI oder via SQL: ```sql UPDATE role SET data = JSON_SET(data, '$.table.CMyEntity', JSON_OBJECT('create', 'yes', 'read', 'all', 'edit', 'all', 'delete', 'all') ) WHERE name = 'RoleName'; ``` --- ## Deployment-Prozess ### Standard-Workflow ```bash # 1. Code-Änderungen durchführen vim custom/Espo/Custom/Resources/metadata/entityDefs/CMyEntity.json # 2. Validierung + Rebuild python3 custom/scripts/validate_and_rebuild.py # 3. Bei Erfolg: Commit git add custom/ git commit -m "feat: Add CMyEntity with custom fields" git push ``` ### Quick Rebuild (nach kleinen Änderungen) ```bash docker exec espocrm php command.php clear-cache docker exec espocrm php command.php rebuild ``` ### Nach Änderungen an Relationships **IMMER:** 1. Cache löschen 2. Rebuild ausführen 3. Browser-Cache löschen (Ctrl+F5) --- ## Troubleshooting ### Rebuild schlägt fehl **1. Logs prüfen:** ```bash python3 custom/scripts/validate_and_rebuild.py # → Zeigt automatisch Fehlerlog-Analyse ``` **2. Manuell Logs checken:** ```bash tail -100 data/logs/espo-$(date +%Y-%m-%d).log ``` **3. PHP-Fehler:** ```bash docker exec espocrm php -l custom/Espo/Custom/Controllers/MyController.php ``` ### Entity nicht sichtbar **Checklist:** - [ ] `tab: true` in scopes? - [ ] `disabled: false` in scopes? - [ ] ACL-Rechte für Role? - [ ] Cache gelöscht? - [ ] Rebuild durchgeführt? ### Relationship funktioniert nicht **Checklist:** - [ ] Bidirektional konfiguriert? - [ ] `foreign` zeigt korrekt zurück? - [ ] `relationName` identisch (bei M2M)? - [ ] Rebuild durchgeführt? ### API gibt 404 **Checklist:** - [ ] Controller existiert? - [ ] Service existiert? - [ ] Action-Methode korrekt benannt? (postAction..., getAction...) - [ ] ACL-Rechte? ### ⚠️ KRITISCH: Rebuild schlägt fehl - "Column does not exist" **Fehlermeldung:** ``` Doctrine\DBAL\Schema\SchemaException::columnDoesNotExist('feldname', 'tabelle') #2 /var/www/html/application/Espo/Core/Utils/Database/Schema/Builder.php(154): Doctrine\DBAL\Schema\Table->addIndex(Array, 'IDX_FELDNAME', Array) ``` **Symptome:** - Rebuild schlägt fehl - JSON/PHP-Validierung erfolgreich - Fehlermeldung referenziert nicht existierendes Feld - Error tritt in Schema-Builder auf **Ursache:** Ein **Index** wurde für ein Feld definiert, das nicht (mehr) existiert. **Häufigster Fall:** 1. Feld wird aus entityDefs entfernt 2. Index-Definition wird vergessen 3. Rebuild versucht Index auf nicht-existentes Feld zu erstellen **Beispiel aus Praxis:** ```json // CDokumente.json { "fields": { // "aktennr" wurde entfernt ← Feld gelöscht }, "indexes": { "aktennr": { ← Index noch da! "columns": ["aktennr"] } } } ``` **Lösung:** **Schritt 1:** Identifiziere betroffenes Feld und Entity aus Error-Log ``` columnDoesNotExist('aktennr', 'c_dokumente') ^^^^^^^^ ^^^^^^^^^^^^ Feld Tabelle ``` **Schritt 2:** Öffne entityDefs-Datei ```bash code custom/Espo/Custom/Resources/metadata/entityDefs/CDokumente.json ``` **Schritt 3:** Suche Index-Definition und entferne sie ```json // VORHER: "indexes": { "createdAtId": {...}, "aktennr": { ← ENTFERNEN "columns": ["aktennr"] }, "md5sum": {...} } // NACHHER: "indexes": { "createdAtId": {...}, "md5sum": {...} } ``` **Schritt 4:** Rebuild erneut durchführen ```bash python3 custom/scripts/validate_and_rebuild.py ``` **Best Practice:** Bei Feld-Entfernung immer prüfen: 1. Feld aus `fields` entfernt? 2. Link aus `links` entfernt? 3. **Index aus `indexes` entfernt?** ← Oft vergessen! 4. Layout-Definitionen aktualisiert? 5. i18n-Einträge bereinigt? ### ⚠️ KRITISCH: InjectableFactory Error (Service-Klasse fehlt) **Fehlermeldung in Logs:** ``` CRITICAL: (0) InjectableFactory: Class 'Espo\Custom\Services\{EntityName}' does not exist. :: GET /{EntityName} :: /var/www/html/application/Espo/Core/InjectableFactory.php(164) ``` **Symptome:** - Entity in UI sichtbar, aber nicht aufrufbar - API-Requests schlagen fehl (leer oder 500 Error) - Fehler tritt bei JEDEM Zugriff auf die Entity auf - Rebuild erfolgreich, aber Funktionalität fehlt **Ursache:** Für die Custom Entity `{EntityName}` wurde keine Service-Klasse erstellt. EspoCRM sucht nach `custom/Espo/Custom/Services/{EntityName}.php` und findet sie nicht. **Lösung:** **Schritt 1:** Erstelle Service-Datei ```bash # Erstelle Datei touch custom/Espo/Custom/Services/{EntityName}.php ``` **Schritt 2:** Füge minimale Service-Klasse ein **Datei:** `custom/Espo/Custom/Services/{EntityName}.php` ```php custom/Espo/Custom/Services/CAICollection.php << 'EOF' custom/Espo/Custom/Services/CAdvowareAkten.php << 'EOF' /tmp/project-overview.txt # → Gibt vollständigen Projekt-Status für AI aus ``` --- ## Ressourcen ### Dokumentation - **EspoCRM Docs:** https://docs.espocrm.com/ - **API Reference:** https://docs.espocrm.com/development/api/ - **Formula Functions:** https://docs.espocrm.com/administration/formula/ ### Projekt-Dokumentation - `custom/docs/ESPOCRM_BEST_PRACTICES.md` - Dieses Dokument - `custom/scripts/QUICKSTART.md` - Quick Start Guide - `custom/scripts/VALIDATION_TOOLS.md` - Validierungs-Tools - `custom/scripts/E2E_TESTS_README.md` - E2E Tests - `custom/README.md` - Custom Actions Blueprint - `custom/TESTERGEBNISSE_JUNCTION_TABLE.md` - Junction Table Implementation --- ## Glossar **ACL** - Access Control List (Zugriffsrechte) **Entity** - Datenmodell (z.B. CMietobjekt) **Link** - Relationship zwischen Entities **Junction Table** - Verbindungstabelle für Many-to-Many **Formula** - Berechnete Felder oder Automation-Scripts **Scope** - Entity-Konfiguration (Tab, ACL, etc.) **Stream** - Activity Feed einer Entity **Hook** - Lifecycle-Event-Handler **Service** - Business Logic Layer **Controller** - API Request Handler **Repository** - Data Access Layer --- **Ende der Best Practices Dokumentation** Für spezifische Fragen oder Updates: Siehe `/custom/docs/` Verzeichnis.