2339 lines
59 KiB
Markdown
2339 lines
59 KiB
Markdown
# 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
|
|
<?php
|
|
namespace Espo\Custom\Services;
|
|
|
|
use Espo\Services\Record;
|
|
|
|
/**
|
|
* Service für {EntityName} Entity
|
|
*/
|
|
class {EntityName} extends Record
|
|
{
|
|
// Basis-Service-Funktionalität wird von Record geerbt
|
|
// Custom Business Logic kann hier hinzugefügt werden
|
|
}
|
|
```
|
|
|
|
**Minimale Service-Klasse:**
|
|
- Erbt von `\Espo\Services\Record`
|
|
- Muss im Namespace `Espo\Custom\Services` sein
|
|
- Klassenname muss exakt dem Entity-Namen entsprechen
|
|
- Mindestens leerer Body erforderlich
|
|
|
|
**Erweiterte Service mit Custom Logic:**
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Services;
|
|
|
|
use Espo\Services\Record;
|
|
use Espo\Core\Exceptions\Forbidden;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
|
|
class {EntityName} extends Record
|
|
{
|
|
/**
|
|
* Custom Business Logic Methode
|
|
*/
|
|
public function customAction(string $id, \stdClass $data): array
|
|
{
|
|
// ACL Check
|
|
if (!$this->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
|
|
<?php
|
|
// Controllers/CAICollectionCDokumente.php
|
|
namespace Espo\Custom\Controllers;
|
|
use Espo\Core\Controllers\Record;
|
|
|
|
class CAICollectionCDokumente extends Record
|
|
{
|
|
// Erbt alle CRUD-Operationen
|
|
}
|
|
|
|
// Services/CAICollectionCDokumente.php
|
|
namespace Espo\Custom\Services;
|
|
use Espo\Services\Record;
|
|
|
|
class CAICollectionCDokumente extends Record
|
|
{
|
|
// Standard-Logik
|
|
}
|
|
```
|
|
|
|
**API-Zugriff:**
|
|
```bash
|
|
# Alle Junction-Einträge
|
|
GET /api/v1/CAICollectionCDokumente
|
|
|
|
# Filtern nach Dokument
|
|
GET /api/v1/CAICollectionCDokumente?where[0][type]=equals&where[0][attribute]=cDokumenteId&where[0][value]=doc123
|
|
|
|
# Neuen Eintrag erstellen
|
|
POST /api/v1/CAICollectionCDokumente
|
|
{
|
|
"cDokumenteId": "doc123",
|
|
"cAICollectionsId": "col456",
|
|
"syncId": "SYNC-2026-001"
|
|
}
|
|
```
|
|
|
|
**WICHTIG:** additionalColumns funktionieren NICHT über Standard-Relationship-Endpoints! Nur über Junction-Entity-API!
|
|
|
|
#### Junction-Spalten im UI anzeigen: columnAttributeMap & notStorable
|
|
|
|
**Problem:** additionalColumns sind nur via Junction-Entity-API zugänglich, nicht in Relationship-Panels.
|
|
|
|
**Lösung:** columnAttributeMap + notStorable Felder für UI-Anzeige
|
|
|
|
**Beispiel:** Dokumente mit HNR und Sync-Status in AdvowareAkten
|
|
|
|
**Parent Entity (CAdvowareAkten):**
|
|
```json
|
|
{
|
|
"fields": {
|
|
"dokumenteHnr": {
|
|
"type": "int",
|
|
"notStorable": true,
|
|
"utility": true
|
|
},
|
|
"dokumenteSyncstatus": {
|
|
"type": "enum",
|
|
"options": ["new", "unclean", "synced", "failed"],
|
|
"notStorable": true,
|
|
"utility": true
|
|
},
|
|
"dokumenteLastSync": {
|
|
"type": "datetime",
|
|
"notStorable": true,
|
|
"utility": true
|
|
},
|
|
"dokumentes": {
|
|
"type": "linkMultiple",
|
|
"columns": {
|
|
"hnr": "advowareAktenHnr",
|
|
"syncstatus": "advowareAktenSyncstatus",
|
|
"lastSync": "advowareAktenLastSync"
|
|
},
|
|
"view": "views/fields/link-multiple-with-columns"
|
|
}
|
|
},
|
|
"links": {
|
|
"dokumentes": {
|
|
"type": "hasMany",
|
|
"entity": "CDokumente",
|
|
"foreign": "advowareAktens",
|
|
"relationName": "cAdvowareAktenDokumente",
|
|
"additionalColumns": {
|
|
"hnr": {"type": "int"},
|
|
"syncstatus": {"type": "varchar", "len": 20},
|
|
"lastSync": {"type": "datetime"}
|
|
},
|
|
"columnAttributeMap": {
|
|
"hnr": "dokumenteHnr",
|
|
"syncstatus": "dokumenteSyncstatus",
|
|
"lastSync": "dokumenteLastSync"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Foreign Entity (CDokumente):**
|
|
```json
|
|
{
|
|
"fields": {
|
|
"advowareAktenHnr": {
|
|
"type": "int",
|
|
"notStorable": true,
|
|
"utility": true,
|
|
"layoutAvailabilityList": ["listForAdvowareAkten"]
|
|
},
|
|
"advowareAktenSyncstatus": {
|
|
"type": "varchar",
|
|
"notStorable": true,
|
|
"utility": true,
|
|
"layoutAvailabilityList": ["listForAdvowareAkten"]
|
|
},
|
|
"advowareAktenLastSync": {
|
|
"type": "datetime",
|
|
"notStorable": true,
|
|
"utility": true,
|
|
"layoutAvailabilityList": ["listForAdvowareAkten"]
|
|
}
|
|
},
|
|
"links": {
|
|
"advowareAktens": {
|
|
"type": "hasMany",
|
|
"entity": "CAdvowareAkten",
|
|
"foreign": "dokumentes",
|
|
"relationName": "cAdvowareAktenDokumente",
|
|
"columnAttributeMap": {
|
|
"hnr": "advowareAktenHnr",
|
|
"syncstatus": "advowareAktenSyncstatus",
|
|
"lastSync": "advowareAktenLastSync"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Custom List Layout (layouts/CDokumente/listForAdvowareAkten.json):**
|
|
```json
|
|
[
|
|
{"name": "name", "width": 25},
|
|
{"name": "advowareAktenHnr", "width": 10},
|
|
{"name": "advowareAktenSyncstatus", "width": 12},
|
|
{"name": "advowareAktenLastSync", "width": 15},
|
|
{"name": "description", "width": 20},
|
|
{"name": "dokument", "width": 18}
|
|
]
|
|
```
|
|
|
|
**Wichtige Konzepte:**
|
|
- **notStorable**: Feld wird nicht in Haupttabelle gespeichert
|
|
- **utility**: Internes Feld, nicht in Standard-Formularen
|
|
- **columnAttributeMap**: Bidirektionales Mapping Junction → UI
|
|
- **layoutAvailabilityList**: Begrenzt Sichtbarkeit auf bestimmte Layouts
|
|
- **columns** in linkMultiple-Field: Verbindet UI-Feldnamen mit Junction-Spalten
|
|
|
|
**Workflow:**
|
|
1. EspoCRM liest Junction-Spalten über RDB-Funktionen
|
|
2. Mapped sie via columnAttributeMap zu notStorable Feldern
|
|
3. UI zeigt notStorable Felder in Relationship-Panels an
|
|
4. Updates erfolgen via updateColumns() in Hooks
|
|
|
|
### 4. Parent Relationship (belongsToParent)
|
|
|
|
**Beispiel:** Dokument kann zu Räumungsklage ODER Mietinkasso gehören
|
|
|
|
```json
|
|
{
|
|
"fields": {
|
|
"parentType": {
|
|
"type": "varchar"
|
|
},
|
|
"parentId": {
|
|
"type": "varchar"
|
|
},
|
|
"parentName": {
|
|
"type": "varchar"
|
|
}
|
|
},
|
|
"links": {
|
|
"parent": {
|
|
"type": "belongsToParent",
|
|
"entityList": ["CVmhRumungsklage", "CMietinkasso", "CKuendigung"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API-Entwicklung
|
|
|
|
### REST API Endpoints
|
|
|
|
**Standard CRUD (automatisch verfügbar):**
|
|
```bash
|
|
GET /api/v1/{EntityName} # List
|
|
GET /api/v1/{EntityName}/{id} # Read
|
|
POST /api/v1/{EntityName} # Create
|
|
PUT /api/v1/{EntityName}/{id} # Update
|
|
DELETE /api/v1/{EntityName}/{id} # Delete
|
|
```
|
|
|
|
### Custom API Endpoint erstellen
|
|
|
|
**1. Controller Action:**
|
|
|
|
**Datei:** `custom/Espo/Custom/Controllers/{EntityName}.php`
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Controllers;
|
|
|
|
use Espo\Core\Controllers\Record;
|
|
use Espo\Core\Api\Request;
|
|
|
|
class CMyEntity extends Record
|
|
{
|
|
/**
|
|
* Custom Action: POST /api/v1/CMyEntity/action/doSomething
|
|
*/
|
|
public function postActionDoSomething(Request $request): array
|
|
{
|
|
$data = $request->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
|
|
<?php
|
|
namespace Espo\Custom\Services;
|
|
|
|
use Espo\Services\Record;
|
|
use Espo\Core\Exceptions\Forbidden;
|
|
use Espo\Core\Exceptions\NotFound;
|
|
|
|
class CMyEntity extends Record
|
|
{
|
|
public function doSomething(string $id, \stdClass $data): array
|
|
{
|
|
// ACL Check
|
|
if (!$this->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
|
|
<?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
|
|
|
|
### Hook-Pattern: Dokumenten-Propagierung mit Loop-Schutz
|
|
|
|
**Use Case:** Automatische Verknüpfung von Dokumenten zwischen hierarchisch verbundenen Entities:
|
|
- Räumungsklage ↔ AdvowareAkten ↔ AIKnowledge
|
|
- Mietinkasso ↔ AdvowareAkten ↔ AIKnowledge
|
|
|
|
**Challenge:** Vermeide Endlos-Rekursion bei gegenseitiger Propagierung
|
|
|
|
**Lösung:** AfterRelate/AfterUnrelate Hooks mit statischem Processing-Array
|
|
|
|
**Pattern-Beispiel (CVmhRumungsklage → AdvowareAkten + AIKnowledge):**
|
|
|
|
```php
|
|
<?php
|
|
namespace Espo\Custom\Hooks\CVmhRumungsklage;
|
|
|
|
use Espo\ORM\Entity;
|
|
use Espo\Core\Hook\Hook\AfterRelate;
|
|
use Espo\Core\Hook\Hook\AfterUnrelate;
|
|
|
|
class PropagateDocuments implements AfterRelate, AfterUnrelate
|
|
{
|
|
private static array $processing = [];
|
|
|
|
public function __construct(
|
|
private \Espo\ORM\EntityManager $entityManager
|
|
) {}
|
|
|
|
public function afterRelate(
|
|
Entity $entity,
|
|
string $relationName,
|
|
Entity $foreignEntity,
|
|
array $columnData,
|
|
\Espo\ORM\Repository\Option\RelateOptions $options
|
|
): void {
|
|
if ($relationName !== 'dokumentesvmhraumungsklage') {
|
|
return;
|
|
}
|
|
|
|
// Loop-Schutz: Eindeutiger Key pro Operation
|
|
$key = $entity->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
|
|
<?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
|
|
|
|
**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
|
|
<?php
|
|
namespace Espo\Custom\Services;
|
|
|
|
use Espo\Services\Record;
|
|
|
|
/**
|
|
* Service für {EntityName} Entity
|
|
*/
|
|
class {EntityName} extends Record
|
|
{
|
|
// Basis-Funktionalität wird von Record geerbt
|
|
}
|
|
```
|
|
|
|
**Schritt 3:** Rebuild + Cache Clear
|
|
|
|
```bash
|
|
python3 custom/scripts/validate_and_rebuild.py
|
|
```
|
|
|
|
**Schritt 4:** Verifizieren
|
|
|
|
```bash
|
|
# Prüfe ob Fehler weg sind
|
|
docker exec espocrm bash -c 'tail -n 50 /var/www/html/data/logs/espo-$(date +%Y-%m-%d).log | grep -i "InjectableFactory"'
|
|
|
|
# Test API-Zugriff
|
|
docker exec espocrm bash -c 'curl -s -X GET "http://localhost/api/v1/{EntityName}" -u "admin:admin"'
|
|
```
|
|
|
|
**Prävention:**
|
|
1. ✅ **Immer** Service-Klasse bei Entity-Erstellung mit anlegen
|
|
2. ✅ Nutze `validate_and_rebuild.py` - detektiert fehlende Services
|
|
3. ✅ Prüfe Logs nach Rebuild auf InjectableFactory-Fehler
|
|
4. ✅ Teste Entity-Zugriff nach Erstellung
|
|
|
|
**Real-World-Beispiel (März 2026):**
|
|
|
|
**Problem:**
|
|
- Entities `CAICollection` und `CAdvowareAkten` nicht aufrufbar
|
|
- Logs zeigten: `Class 'Espo\Custom\Services\CAICollection' does not exist`
|
|
|
|
**Lösung:**
|
|
```bash
|
|
# Service-Klassen erstellt
|
|
cat > custom/Espo/Custom/Services/CAICollection.php << 'EOF'
|
|
<?php
|
|
namespace Espo\Custom\Services;
|
|
use Espo\Services\Record;
|
|
class CAICollection extends Record {}
|
|
EOF
|
|
|
|
cat > custom/Espo/Custom/Services/CAdvowareAkten.php << 'EOF'
|
|
<?php
|
|
namespace Espo\Custom\Services;
|
|
use Espo\Services\Record;
|
|
class CAdvowareAkten extends Record {}
|
|
EOF
|
|
|
|
# Rebuild
|
|
python3 custom/scripts/validate_and_rebuild.py
|
|
```
|
|
|
|
**Ergebnis:** ✅ Beide Entities sofort funktionsfähig, keine Fehler mehr in Logs
|
|
|
|
### Formula triggert nicht
|
|
|
|
**Checklist:**
|
|
- [ ] In `metadata/formula/` statt entityDefs?
|
|
- [ ] 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
|
|
|
|
### Übersicht
|
|
|
|
1. **CMietobjekt** - Mietobjekte (Wohnungen/Häuser)
|
|
2. **CVmhMietverhltnis** - Mietverhältnisse
|
|
3. **CKuendigung** - Kündigungen
|
|
4. **CBeteiligte** - Beteiligte Personen
|
|
5. **CMietinkasso** - Mietinkasso-Verfahren
|
|
6. **CVmhRumungsklage** - Räumungsklagen
|
|
7. **CDokumente** - Dokumente
|
|
8. **CPuls** - Puls-System (Entwicklungen)
|
|
9. **CAICollections** - AI Collections
|
|
|
|
### Entity-Graph
|
|
|
|
```
|
|
CMietobjekt
|
|
├── CVmhMietverhltnis (hasMany)
|
|
│ ├── CKuendigung (hasMany)
|
|
│ │ └── CVmhRumungsklage (hasOne)
|
|
│ ├── CMietinkasso (hasMany)
|
|
│ └── CBeteiligte (hasMany)
|
|
└── Contact (hasMany)
|
|
|
|
CDokumente
|
|
├── parent → [CVmhRumungsklage, CMietinkasso, CKuendigung]
|
|
└── CAICollections (hasMany via Junction)
|
|
└── CPuls (hasMany)
|
|
```
|
|
|
|
---
|
|
|
|
## Tools & Scripts
|
|
|
|
### Übersicht
|
|
|
|
| Tool | Zweck | Ausführung |
|
|
|------|-------|-----------|
|
|
| validate_and_rebuild.py | Validierung + Rebuild | `python3 custom/scripts/validate_and_rebuild.py` |
|
|
| e2e_tests.py | End-to-End Tests | `python3 custom/scripts/e2e_tests.py` |
|
|
| ki_project_overview.py | Projekt-Analyse für AI | `python3 custom/scripts/ki_project_overview.py` |
|
|
| workflow_manager.php | Workflow-Verwaltung | `php custom/scripts/workflow_manager.php list` |
|
|
|
|
### KI-Projekt-Übersicht
|
|
|
|
**Für AI Code Agents:**
|
|
```bash
|
|
python3 custom/scripts/ki_project_overview.py > /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.
|