975 lines
29 KiB
Markdown
975 lines
29 KiB
Markdown
# EspoCRM Custom Actions - Implementierungsprinzip
|
||
|
||
**Version:** 1.0
|
||
**Datum:** 24. Januar 2026
|
||
**Blueprint für:** Custom Button Actions mit Entity-Erstellung und Relationen
|
||
|
||
---
|
||
|
||
## 📋 Inhaltsverzeichnis
|
||
|
||
1. [Überblick](#überblick)
|
||
2. [Architektur-Prinzipien](#architektur-prinzipien)
|
||
3. [Implementierungs-Schritte](#implementierungs-schritte)
|
||
4. [Code-Template](#code-template)
|
||
5. [Best Practices](#best-practices)
|
||
6. [Sicherheit](#sicherheit)
|
||
7. [Testing & Debugging](#testing--debugging)
|
||
8. [Beispiel-Implementierung](#beispiel-implementierung)
|
||
|
||
---
|
||
|
||
## Überblick
|
||
|
||
Dieses Dokument beschreibt das standardisierte Vorgehen zur Implementierung von Custom Button Actions in EspoCRM, die:
|
||
- Von einer Entity-Detailansicht ausgelöst werden
|
||
- Eine neue verwandte Entity erstellen
|
||
- Automatisch Daten und Relationen übertragen
|
||
- ACL-konform und transaktionssicher arbeiten
|
||
|
||
**Basis-Beispiel:** Button "Räumungsklage einleiten" in Mietobjekt → Erstellt CVmhRumungsklage mit allen Relationen
|
||
|
||
---
|
||
|
||
## Architektur-Prinzipien
|
||
|
||
### 1. Drei-Schichten-Architektur
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ FRONTEND (client/custom/) │
|
||
│ • JavaScript Handler (Action Trigger) │
|
||
│ • Button Definition (clientDefs) │
|
||
│ • i18n Translations │
|
||
└────────────────┬────────────────────────────────────┘
|
||
│ AJAX POST Request
|
||
┌────────────────▼────────────────────────────────────┐
|
||
│ CONTROLLER (custom/Espo/Custom/Controllers/) │
|
||
│ • Request Validation │
|
||
│ • Service Call │
|
||
│ • Response Formatting │
|
||
└────────────────┬────────────────────────────────────┘
|
||
│ Method Call
|
||
┌────────────────▼────────────────────────────────────┐
|
||
│ SERVICE (custom/Espo/Custom/Services/) │
|
||
│ • ACL Security Checks │
|
||
│ • Business Logic │
|
||
│ • Transaction Management │
|
||
│ • Entity Operations │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 2. Separation of Concerns
|
||
|
||
| Schicht | Verantwortlichkeit | Nicht erlaubt |
|
||
|---------|-------------------|---------------|
|
||
| **Frontend** | User Interaction, Confirmation, Navigation | Business Logic, Direct DB Access |
|
||
| **Controller** | HTTP Request/Response Handling | Business Logic, Direct Entity Manipulation |
|
||
| **Service** | Business Logic, ACL, Transactions, Entity Operations | HTTP Handling, View Logic |
|
||
|
||
---
|
||
|
||
## Implementierungs-Schritte
|
||
|
||
### Schritt 1: Backend Controller erstellen
|
||
|
||
**Datei:** `custom/Espo/Custom/Controllers/[EntityName].php`
|
||
|
||
```php
|
||
<?php
|
||
namespace Espo\Custom\Controllers;
|
||
|
||
use Espo\Core\Exceptions\BadRequest;
|
||
use Espo\Core\Api\Request;
|
||
|
||
class CYourEntity extends \Espo\Core\Templates\Controllers\Base
|
||
{
|
||
/**
|
||
* POST Action: Beschreibung der Aktion
|
||
*/
|
||
public function postActionYourAction(Request $request): array
|
||
{
|
||
$data = $request->getParsedBody();
|
||
|
||
$id = $data->id ?? null;
|
||
if (!$id) {
|
||
throw new BadRequest('No Entity ID provided');
|
||
}
|
||
|
||
$service = $this->getRecordService();
|
||
$result = $service->yourAction($id);
|
||
|
||
return $result;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Wichtig:**
|
||
- Methode MUSS `postAction[Name]` heißen
|
||
- Parameter ist `Request $request`
|
||
- Return Type ist `array`
|
||
- Extends `\Espo\Core\Templates\Controllers\Base`
|
||
|
||
---
|
||
|
||
### Schritt 2: Backend Service erstellen
|
||
|
||
**Datei:** `custom/Espo/Custom/Services/[EntityName].php`
|
||
|
||
```php
|
||
<?php
|
||
namespace Espo\Custom\Services;
|
||
|
||
use Espo\Core\Exceptions\Forbidden;
|
||
use Espo\Core\Exceptions\NotFound;
|
||
|
||
class CYourEntity extends \Espo\Services\Record
|
||
{
|
||
/**
|
||
* Beschreibung der Business-Logik
|
||
*
|
||
* @param string $sourceId
|
||
* @return array
|
||
* @throws NotFound
|
||
* @throws Forbidden
|
||
*/
|
||
public function yourAction(string $sourceId): array
|
||
{
|
||
// 1. LADEN: Source Entity laden
|
||
$source = $this->entityManager->getEntity('CYourEntity', $sourceId);
|
||
if (!$source) {
|
||
throw new NotFound('Entity not found');
|
||
}
|
||
|
||
// 2. SECURITY: ACL-Checks
|
||
if (!$this->acl->check($source, 'read')) {
|
||
throw new Forbidden('No read access to source entity');
|
||
}
|
||
|
||
if (!$this->acl->checkScope('CTargetEntity', 'create')) {
|
||
throw new Forbidden('No create access to target entity');
|
||
}
|
||
|
||
// 3. TRANSACTION: Start
|
||
$this->entityManager->getTransactionManager()->start();
|
||
|
||
try {
|
||
// 4. ERSTELLEN: Target Entity
|
||
$data = new \stdClass();
|
||
$data->name = 'Generated from ' . $source->get('name');
|
||
|
||
// Optional: User und Teams übernehmen
|
||
if ($source->get('assignedUserId')) {
|
||
$data->assignedUserId = $source->get('assignedUserId');
|
||
}
|
||
|
||
$teamsIds = $source->getLinkMultipleIdList('teams');
|
||
if (!empty($teamsIds)) {
|
||
$data->teamsIds = $teamsIds;
|
||
}
|
||
|
||
$target = $this->entityManager->createEntity('CTargetEntity', (array)$data);
|
||
|
||
if (!$target) {
|
||
throw new \RuntimeException('Failed to create target entity');
|
||
}
|
||
|
||
// 5. RELATIONEN: Verknüpfen
|
||
$targetRepo = $this->entityManager->getRepository('CTargetEntity');
|
||
|
||
// Source → Target
|
||
$targetRepo
|
||
->getRelation($target, 'sources')
|
||
->relate($source);
|
||
|
||
// Related Entities übertragen
|
||
$relatedEntities = $this->entityManager
|
||
->getRepository('CYourEntity')
|
||
->getRelation($source, 'relatedThings')
|
||
->find();
|
||
|
||
foreach ($relatedEntities as $related) {
|
||
$targetRepo
|
||
->getRelation($target, 'relatedThings')
|
||
->relate($related);
|
||
}
|
||
|
||
// 6. COMMIT: Transaktion abschließen
|
||
$this->entityManager->getTransactionManager()->commit();
|
||
|
||
// 7. RETURN: ID und Name für Frontend
|
||
return [
|
||
'id' => $target->getId(),
|
||
'name' => $target->get('name')
|
||
];
|
||
|
||
} catch (\Exception $e) {
|
||
// ROLLBACK: Bei jedem Fehler
|
||
$this->entityManager->getTransactionManager()->rollback();
|
||
throw $e;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Wichtig:**
|
||
- Extends `\Espo\Services\Record`
|
||
- Immer Transactions verwenden
|
||
- Alle Exceptions im catch-Block fangen
|
||
- Rollback bei Fehlern
|
||
|
||
---
|
||
|
||
### Schritt 3: Button im Frontend konfigurieren
|
||
|
||
**Datei:** `custom/Espo/Custom/Resources/metadata/clientDefs/[EntityName].json`
|
||
|
||
```json
|
||
{
|
||
"menu": {
|
||
"detail": {
|
||
"buttons": [
|
||
{
|
||
"name": "initiateYourAction",
|
||
"label": "Your Action Button",
|
||
"handler": "custom:handlers/your-entity/your-action",
|
||
"iconHtml": "<span class=\"fas fa-bolt\"></span>",
|
||
"style": "danger",
|
||
"acl": "edit"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Button-Optionen:**
|
||
- `name`: Eindeutiger Identifier
|
||
- `label`: Translation-Key (siehe i18n)
|
||
- `handler`: Pfad zum JavaScript Handler
|
||
- `iconHtml`: FontAwesome Icon oder HTML
|
||
- `style`: `default`, `success`, `danger`, `warning`, `info`
|
||
- `acl`: `read`, `edit`, `delete` (benötigte Permission)
|
||
|
||
---
|
||
|
||
### Schritt 4: JavaScript Handler implementieren
|
||
|
||
**Datei:** `client/custom/src/handlers/your-entity/your-action.js`
|
||
|
||
```javascript
|
||
define('custom:handlers/your-entity/your-action', [], function () {
|
||
|
||
class YourActionHandler {
|
||
|
||
constructor(view) {
|
||
this.view = view;
|
||
}
|
||
|
||
/**
|
||
* Setup-Methode wird automatisch aufgerufen
|
||
*/
|
||
initYourAction() {
|
||
this.view.listenTo(
|
||
this.view,
|
||
'after:render',
|
||
() => this.controlButtonVisibility()
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Button-Sichtbarkeit steuern (optional)
|
||
*/
|
||
controlButtonVisibility() {
|
||
const model = this.view.model;
|
||
|
||
// Beispiel: Button nur bei bestimmten Bedingungen zeigen
|
||
if (!model.get('someField')) {
|
||
this.view.hideHeaderActionItem('initiateYourAction');
|
||
} else {
|
||
this.view.showHeaderActionItem('initiateYourAction');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Action-Handler (wird bei Button-Click aufgerufen)
|
||
*/
|
||
actionInitiateYourAction() {
|
||
const model = this.view.model;
|
||
|
||
// 1. CONFIRMATION: User bestätigen lassen
|
||
this.view.confirm(
|
||
this.view.translate('confirmationMessage', 'messages', 'CYourEntity'),
|
||
() => {
|
||
this.initiateAction(model.id);
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* AJAX Request zum Backend
|
||
*/
|
||
initiateAction(entityId) {
|
||
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
|
||
|
||
Espo.Ajax.postRequest('CYourEntity/action/yourAction', {
|
||
id: entityId
|
||
})
|
||
.then(response => {
|
||
Espo.Ui.success(
|
||
this.view.translate('successMessage', 'messages', 'CYourEntity')
|
||
);
|
||
|
||
// Navigation zur erstellten Entity
|
||
this.view.getRouter().navigate(
|
||
'#CTargetEntity/view/' + response.id,
|
||
{trigger: true}
|
||
);
|
||
})
|
||
.catch(xhr => {
|
||
console.error('Action failed:', xhr);
|
||
|
||
let errorMessage = this.view.translate('errorMessage', 'messages', 'CYourEntity');
|
||
|
||
if (xhr.status === 403) {
|
||
errorMessage = this.view.translate('Access denied', 'messages');
|
||
} else if (xhr.status === 404) {
|
||
errorMessage = this.view.translate('Not found', 'messages');
|
||
}
|
||
|
||
Espo.Ui.error(errorMessage);
|
||
});
|
||
}
|
||
}
|
||
|
||
return YourActionHandler;
|
||
});
|
||
```
|
||
|
||
**Wichtig:**
|
||
- Class-Name muss mit Filename übereinstimmen (CamelCase)
|
||
- Methode `init[ButtonName]()` für Setup
|
||
- Methode `action[ButtonName]()` für Click-Handler
|
||
- Immer Confirmation Dialog für kritische Actions
|
||
- Error Handling mit spezifischen HTTP-Status-Codes
|
||
|
||
---
|
||
|
||
### Schritt 5: Übersetzungen hinzufügen
|
||
|
||
**Datei:** `custom/Espo/Custom/Resources/i18n/de_DE/[EntityName].json`
|
||
|
||
```json
|
||
{
|
||
"labels": {
|
||
"Your Action Button": "Ihre Aktion"
|
||
},
|
||
"messages": {
|
||
"confirmationMessage": "Möchten Sie diese Aktion wirklich ausführen?",
|
||
"successMessage": "Die Aktion wurde erfolgreich ausgeführt",
|
||
"errorMessage": "Fehler beim Ausführen der Aktion"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Datei:** `custom/Espo/Custom/Resources/i18n/en_US/[EntityName].json`
|
||
|
||
```json
|
||
{
|
||
"labels": {
|
||
"Your Action Button": "Your Action"
|
||
},
|
||
"messages": {
|
||
"confirmationMessage": "Do you really want to execute this action?",
|
||
"successMessage": "Action executed successfully",
|
||
"errorMessage": "Error executing action"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Schritt 6: Validierung & Rebuild
|
||
|
||
```bash
|
||
# Im Root-Verzeichnis von EspoCRM
|
||
python3 custom/scripts/validate_and_rebuild.py
|
||
```
|
||
|
||
**Prüfungen:**
|
||
- ✅ JSON-Syntax aller Dateien
|
||
- ✅ PHP-Syntax (Service, Controller)
|
||
- ✅ JavaScript-Syntax (Handler)
|
||
- ✅ Relationship-Konsistenz
|
||
- ✅ Automatischer Cache-Clear & Rebuild
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
### 1. Naming Conventions
|
||
|
||
| Element | Pattern | Beispiel |
|
||
|---------|---------|----------|
|
||
| Controller Action | `postAction[Name]` | `postActionInitiateEviction` |
|
||
| Service Method | `camelCase` | `initiateEviction` |
|
||
| Button Name | `camelCase` | `initiateEviction` |
|
||
| JS Handler File | `kebab-case.js` | `eviction-action.js` |
|
||
| JS Class Name | `PascalCase` | `EvictionActionHandler` |
|
||
| JS Init Method | `init[ButtonName]` | `initInitiateEviction` |
|
||
| JS Action Method | `action[ButtonName]` | `actionInitiateEviction` |
|
||
|
||
### 2. Transaction Management
|
||
|
||
**Immer Transactions verwenden** bei:
|
||
- Entity-Erstellung
|
||
- Mehreren Relationierungen
|
||
- Datenänderungen über mehrere Entities
|
||
|
||
```php
|
||
// ✅ RICHTIG
|
||
$this->entityManager->getTransactionManager()->start();
|
||
try {
|
||
// ... operations
|
||
$this->entityManager->getTransactionManager()->commit();
|
||
} catch (\Exception $e) {
|
||
$this->entityManager->getTransactionManager()->rollback();
|
||
throw $e;
|
||
}
|
||
|
||
// ❌ FALSCH - Keine Transaction
|
||
$entity = $this->entityManager->createEntity(...);
|
||
// Bei Fehler in nächstem Schritt: Inkonsistente Daten!
|
||
$this->entityManager->getRepository(...)->relate(...);
|
||
```
|
||
|
||
### 3. Repository Caching
|
||
|
||
```php
|
||
// ✅ RICHTIG - Repository nur 1× holen
|
||
$targetRepo = $this->entityManager->getRepository('Target');
|
||
foreach ($items as $item) {
|
||
$targetRepo->getRelation($target, 'items')->relate($item);
|
||
}
|
||
|
||
// ❌ FALSCH - Repository in Schleife holen
|
||
foreach ($items as $item) {
|
||
$this->entityManager->getRepository('Target')
|
||
->getRelation($target, 'items')
|
||
->relate($item);
|
||
}
|
||
```
|
||
|
||
### 4. Error Messages
|
||
|
||
```php
|
||
// ✅ RICHTIG - Spezifische Exceptions
|
||
if (!$source) {
|
||
throw new NotFound('Mietobjekt not found');
|
||
}
|
||
if (!$this->acl->check($source, 'read')) {
|
||
throw new Forbidden('No read access to Mietobjekt');
|
||
}
|
||
|
||
// ❌ FALSCH - Generische Fehler
|
||
if (!$source) {
|
||
throw new \Exception('Error');
|
||
}
|
||
```
|
||
|
||
### 5. Null Checks
|
||
|
||
```php
|
||
// ✅ RICHTIG
|
||
$entity = $this->entityManager->createEntity(...);
|
||
if (!$entity) {
|
||
throw new \RuntimeException('Failed to create entity');
|
||
}
|
||
|
||
// ❌ FALSCH - Keine Prüfung
|
||
$entity = $this->entityManager->createEntity(...);
|
||
$entity->getId(); // Kann zu Fehler führen!
|
||
```
|
||
|
||
---
|
||
|
||
## Sicherheit
|
||
|
||
### 1. ACL-Checks (Access Control Lists)
|
||
|
||
**Minimum:** Immer beide Checks durchführen:
|
||
|
||
```php
|
||
// Check 1: Kann User Source-Entity lesen?
|
||
if (!$this->acl->check($sourceEntity, 'read')) {
|
||
throw new Forbidden('No read access');
|
||
}
|
||
|
||
// Check 2: Kann User Target-Entity erstellen?
|
||
if (!$this->acl->checkScope('TargetEntity', 'create')) {
|
||
throw new Forbidden('No create access');
|
||
}
|
||
```
|
||
|
||
**Optional:** Zusätzliche Checks je nach Business-Logik:
|
||
|
||
```php
|
||
// Check 3: Kann User Source-Entity bearbeiten?
|
||
if (!$this->acl->check($sourceEntity, 'edit')) {
|
||
throw new Forbidden('No edit access');
|
||
}
|
||
|
||
// Check 4: Ownership-Check
|
||
if ($sourceEntity->get('assignedUserId') !== $this->user->getId()) {
|
||
throw new Forbidden('Not the owner');
|
||
}
|
||
```
|
||
|
||
### 2. Input Validation
|
||
|
||
```php
|
||
// ✅ RICHTIG - ID validieren
|
||
$id = $data->id ?? null;
|
||
if (!$id || !is_string($id)) {
|
||
throw new BadRequest('Invalid ID provided');
|
||
}
|
||
|
||
// Optional: Format prüfen
|
||
if (!preg_match('/^[a-f0-9]{17}$/', $id)) {
|
||
throw new BadRequest('Invalid ID format');
|
||
}
|
||
```
|
||
|
||
### 3. SQL Injection Prevention
|
||
|
||
**Automatisch geschützt** durch:
|
||
- ✅ EntityManager ORM (immer verwenden!)
|
||
- ✅ Repository Pattern
|
||
- ✅ Prepared Statements
|
||
|
||
**Niemals:**
|
||
- ❌ Direct SQL Queries
|
||
- ❌ String Concatenation in Queries
|
||
- ❌ `$pdo->query()` direkt verwenden
|
||
|
||
### 4. XSS Prevention
|
||
|
||
**Automatisch geschützt** durch:
|
||
- ✅ EspoCRM Template Engine
|
||
- ✅ Automatic HTML Escaping
|
||
- ✅ Content Security Policy Headers
|
||
|
||
**Im Frontend:**
|
||
```javascript
|
||
// ✅ RICHTIG - translate() escaped automatisch
|
||
this.view.translate('message', 'messages', 'Entity');
|
||
|
||
// ❌ FALSCH - Rohe HTML-Injection
|
||
$('.container').html('<div>' + userInput + '</div>');
|
||
```
|
||
|
||
### 5. CSRF Protection
|
||
|
||
**Automatisch geschützt** durch:
|
||
- ✅ EspoCRM API Token System
|
||
- ✅ Espo.Ajax verwendet automatisch CSRF-Token
|
||
- ✅ Session-basierte Validierung
|
||
|
||
**Kein manueller Code nötig!**
|
||
|
||
---
|
||
|
||
## Testing & Debugging
|
||
|
||
### 1. Entwickler-Workflow
|
||
|
||
```bash
|
||
# 1. Code ändern
|
||
vim custom/Espo/Custom/Services/YourEntity.php
|
||
|
||
# 2. Validieren & Rebuild
|
||
python3 custom/scripts/validate_and_rebuild.py
|
||
|
||
# 3. Browser Hard-Refresh
|
||
# Ctrl + Shift + R (Chrome/Firefox)
|
||
# Cmd + Shift + R (Mac)
|
||
|
||
# 4. Test durchführen
|
||
|
||
# 5. Logs prüfen bei Fehlern
|
||
tail -f data/logs/espo-$(date +%Y-%m-%d).log
|
||
```
|
||
|
||
### 2. Häufige Fehler & Lösungen
|
||
|
||
| Fehler | Ursache | Lösung |
|
||
|--------|---------|--------|
|
||
| `Error 500: Call to undefined method getAcl()` | Falsche ACL-Methode | Nutze `$this->acl` statt `$this->getAcl()` |
|
||
| `Error 500: Entity does not have a relation 'xyz'` | Relation existiert nicht | Prüfe entityDefs, korrekter Relationname |
|
||
| Button erscheint nicht | Falsche clientDefs-Struktur | Nutze `menu.detail.buttons` nicht `detailActionList` |
|
||
| JavaScript-Fehler | Handler nicht gefunden | Prüfe Pfad und Class-Name, Cache leeren |
|
||
| Transaction-Fehler | Verschachtelte Transactions | Nur eine Transaction auf Service-Level |
|
||
|
||
### 3. Debugging-Kommandos
|
||
|
||
```bash
|
||
# Logs live verfolgen
|
||
tail -f data/logs/espo-$(date +%Y-%m-%d).log
|
||
|
||
# Letzten Fehler finden
|
||
tail -n 200 data/logs/espo-$(date +%Y-%m-%d).log | grep -i "error"
|
||
|
||
# PHP-Syntax prüfen
|
||
find custom/Espo/Custom -name "*.php" -exec php -l {} \;
|
||
|
||
# JSON-Syntax prüfen
|
||
find custom/Espo/Custom/Resources/metadata -name "*.json" -exec python3 -m json.tool {} \; > /dev/null
|
||
|
||
# Cache manuell löschen
|
||
rm -rf data/cache/*
|
||
```
|
||
|
||
### 4. Browser DevTools
|
||
|
||
**Console (F12):**
|
||
```javascript
|
||
// Current Model inspizieren
|
||
console.log(this.model.attributes);
|
||
|
||
// AJAX Request debuggen
|
||
Espo.Ajax.postRequest('Entity/action/test', {id: 'xyz'})
|
||
.then(r => console.log('Success:', r))
|
||
.catch(e => console.error('Error:', e));
|
||
```
|
||
|
||
---
|
||
|
||
## Beispiel-Implementierung
|
||
|
||
### Use Case: "Räumungsklage einleiten"
|
||
|
||
**Requirement:**
|
||
- Button in Mietobjekt-Detailansicht
|
||
- Erstellt CVmhRumungsklage
|
||
- Verknüpft Mietverhältnisse, Vermieter (Kläger), Mieter (Beklagte)
|
||
- Kopiert assignedUser und Teams
|
||
|
||
### Dateien-Struktur
|
||
|
||
```
|
||
custom/Espo/Custom/
|
||
├── Controllers/
|
||
│ └── CMietobjekt.php # POST Action Endpoint
|
||
├── Services/
|
||
│ └── CMietobjekt.php # Business Logic
|
||
└── Resources/
|
||
├── metadata/clientDefs/
|
||
│ └── CMietobjekt.json # Button Definition
|
||
└── i18n/
|
||
├── de_DE/
|
||
│ └── CMietobjekt.json # Deutsche Übersetzungen
|
||
└── en_US/
|
||
└── CMietobjekt.json # Englische Übersetzungen
|
||
|
||
client/custom/src/handlers/
|
||
└── mietobjekt/
|
||
└── eviction-action.js # Frontend Handler
|
||
```
|
||
|
||
### Vollständiger Code
|
||
|
||
**1. Controller** ([CMietobjekt.php](custom/Espo/Custom/Controllers/CMietobjekt.php)):
|
||
```php
|
||
<?php
|
||
namespace Espo\Custom\Controllers;
|
||
|
||
use Espo\Core\Exceptions\BadRequest;
|
||
use Espo\Core\Api\Request;
|
||
|
||
class CMietobjekt extends \Espo\Core\Templates\Controllers\Base
|
||
{
|
||
public function postActionInitiateEviction(Request $request): array
|
||
{
|
||
$data = $request->getParsedBody();
|
||
$id = $data->id ?? null;
|
||
|
||
if (!$id) {
|
||
throw new BadRequest('No Mietobjekt ID provided');
|
||
}
|
||
|
||
return $this->getRecordService()->initiateEviction($id);
|
||
}
|
||
}
|
||
```
|
||
|
||
**2. Service** ([CMietobjekt.php](custom/Espo/Custom/Services/CMietobjekt.php)):
|
||
```php
|
||
<?php
|
||
namespace Espo\Custom\Services;
|
||
|
||
use Espo\Core\Exceptions\Forbidden;
|
||
use Espo\Core\Exceptions\NotFound;
|
||
|
||
class CMietobjekt extends \Espo\Services\Record
|
||
{
|
||
public function initiateEviction(string $mietobjektId): array
|
||
{
|
||
// 1. Load & Validate
|
||
$mietobjekt = $this->entityManager->getEntity('CMietobjekt', $mietobjektId);
|
||
if (!$mietobjekt) {
|
||
throw new NotFound('Mietobjekt not found');
|
||
}
|
||
|
||
// 2. ACL Checks
|
||
if (!$this->acl->check($mietobjekt, 'read')) {
|
||
throw new Forbidden('No read access to Mietobjekt');
|
||
}
|
||
if (!$this->acl->checkScope('CVmhRumungsklage', 'create')) {
|
||
throw new Forbidden('No create access to Räumungsklage');
|
||
}
|
||
|
||
// 3. Start Transaction
|
||
$this->entityManager->getTransactionManager()->start();
|
||
|
||
try {
|
||
// 4. Create Räumungsklage
|
||
$data = new \stdClass();
|
||
$data->name = 'Räumungsklage - ' . $mietobjekt->get('name');
|
||
|
||
if ($mietobjekt->get('assignedUserId')) {
|
||
$data->assignedUserId = $mietobjekt->get('assignedUserId');
|
||
}
|
||
|
||
$teamsIds = $mietobjekt->getLinkMultipleIdList('teams');
|
||
if (!empty($teamsIds)) {
|
||
$data->teamsIds = $teamsIds;
|
||
}
|
||
|
||
$raeumungsklage = $this->entityManager->createEntity(
|
||
'CVmhRumungsklage',
|
||
(array)$data
|
||
);
|
||
|
||
if (!$raeumungsklage) {
|
||
throw new \RuntimeException('Failed to create Räumungsklage');
|
||
}
|
||
|
||
$repo = $this->entityManager->getRepository('CVmhRumungsklage');
|
||
|
||
// 5. Link Relations
|
||
$repo->getRelation($raeumungsklage, 'mietobjekte')
|
||
->relate($mietobjekt);
|
||
|
||
$mietverhaeltnisse = $this->entityManager
|
||
->getRepository('CMietobjekt')
|
||
->getRelation($mietobjekt, 'vmhMietverhltnises')
|
||
->find();
|
||
|
||
foreach ($mietverhaeltnisse as $mv) {
|
||
$repo->getRelation($raeumungsklage, 'vmhMietverhltnises')
|
||
->relate($mv);
|
||
|
||
// Link Vermieter as Kläger
|
||
$vermieter = $this->entityManager
|
||
->getRepository('CVmhMietverhltnis')
|
||
->getRelation($mv, 'vmhbeteiligtevermieter')
|
||
->find();
|
||
foreach ($vermieter as $v) {
|
||
$repo->getRelation($raeumungsklage, 'klaeger')->relate($v);
|
||
}
|
||
|
||
// Link Mieter as Beklagte
|
||
$mieter = $this->entityManager
|
||
->getRepository('CVmhMietverhltnis')
|
||
->getRelation($mv, 'vmhbeteiligtemieter')
|
||
->find();
|
||
foreach ($mieter as $m) {
|
||
$repo->getRelation($raeumungsklage, 'beklagte')->relate($m);
|
||
}
|
||
}
|
||
|
||
// 6. Commit
|
||
$this->entityManager->getTransactionManager()->commit();
|
||
|
||
return [
|
||
'id' => $raeumungsklage->getId(),
|
||
'name' => $raeumungsklage->get('name')
|
||
];
|
||
|
||
} catch (\Exception $e) {
|
||
$this->entityManager->getTransactionManager()->rollback();
|
||
throw $e;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**3. Button Config** ([clientDefs/CMietobjekt.json](custom/Espo/Custom/Resources/metadata/clientDefs/CMietobjekt.json)):
|
||
```json
|
||
{
|
||
"menu": {
|
||
"detail": {
|
||
"buttons": [
|
||
{
|
||
"name": "initiateEviction",
|
||
"label": "Initiate Eviction",
|
||
"handler": "custom:handlers/mietobjekt/eviction-action",
|
||
"iconHtml": "<span class=\"fas fa-gavel\"></span>",
|
||
"style": "danger",
|
||
"acl": "edit"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**4. JavaScript Handler** ([eviction-action.js](client/custom/src/handlers/mietobjekt/eviction-action.js)):
|
||
```javascript
|
||
define('custom:handlers/mietobjekt/eviction-action', [], function () {
|
||
|
||
class EvictionActionHandler {
|
||
|
||
constructor(view) {
|
||
this.view = view;
|
||
}
|
||
|
||
initInitiateEviction() {
|
||
// Optional: Button-Logik nach Render
|
||
}
|
||
|
||
actionInitiateEviction() {
|
||
this.view.confirm(
|
||
this.view.translate('confirmEviction', 'messages', 'CMietobjekt'),
|
||
() => this.initiateEviction(this.view.model.id)
|
||
);
|
||
}
|
||
|
||
initiateEviction(id) {
|
||
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
|
||
|
||
Espo.Ajax.postRequest('CMietobjekt/action/initiateEviction', { id })
|
||
.then(response => {
|
||
Espo.Ui.success(
|
||
this.view.translate('evictionCreated', 'messages', 'CMietobjekt')
|
||
);
|
||
this.view.getRouter().navigate(
|
||
'#CVmhRumungsklage/view/' + response.id,
|
||
{ trigger: true }
|
||
);
|
||
})
|
||
.catch(xhr => {
|
||
let msg = this.view.translate('evictionError', 'messages', 'CMietobjekt');
|
||
if (xhr.status === 403) {
|
||
msg = this.view.translate('Access denied', 'messages');
|
||
}
|
||
Espo.Ui.error(msg);
|
||
});
|
||
}
|
||
}
|
||
|
||
return EvictionActionHandler;
|
||
});
|
||
```
|
||
|
||
**5. Translations** ([i18n/de_DE/CMietobjekt.json](custom/Espo/Custom/Resources/i18n/de_DE/CMietobjekt.json)):
|
||
```json
|
||
{
|
||
"labels": {
|
||
"Initiate Eviction": "Räumungsklage einleiten"
|
||
},
|
||
"messages": {
|
||
"confirmEviction": "Möchten Sie wirklich eine Räumungsklage einleiten?",
|
||
"evictionCreated": "Räumungsklage wurde erfolgreich erstellt",
|
||
"evictionError": "Fehler beim Erstellen der Räumungsklage"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Checkliste für neue Implementierungen
|
||
|
||
### Vor dem Start
|
||
- [ ] Requirements klar definiert (Welche Entity? Welche Relationen?)
|
||
- [ ] Entity-Relationen in entityDefs geprüft
|
||
- [ ] ACL-Requirements geklärt
|
||
- [ ] Datenfluss dokumentiert
|
||
|
||
### Implementierung
|
||
- [ ] Controller erstellt mit `postAction[Name]` Methode
|
||
- [ ] Service erstellt mit Business-Logik
|
||
- [ ] ACL-Checks: `check()` für Source + `checkScope()` für Target
|
||
- [ ] Transaction: `start()` → `try/catch` → `commit()/rollback()`
|
||
- [ ] Null-Check nach `createEntity()`
|
||
- [ ] Repository-Caching bei Loops
|
||
- [ ] Button in clientDefs konfiguriert (`menu.detail.buttons`)
|
||
- [ ] JavaScript Handler erstellt
|
||
- [ ] Init-Methode: `init[ButtonName]()`
|
||
- [ ] Action-Methode: `action[ButtonName]()`
|
||
- [ ] Confirmation Dialog implementiert
|
||
- [ ] Error Handling mit HTTP-Status-Codes
|
||
- [ ] Translations: Deutsch + Englisch
|
||
- [ ] Labels + Messages vollständig
|
||
|
||
### Testing
|
||
- [ ] Validierung erfolgreich: `validate_and_rebuild.py`
|
||
- [ ] Browser Hard-Refresh durchgeführt
|
||
- [ ] Button erscheint in Detailansicht
|
||
- [ ] Confirmation Dialog funktioniert
|
||
- [ ] Entity wird korrekt erstellt
|
||
- [ ] Relationen sind korrekt verknüpft
|
||
- [ ] Navigation zur neuen Entity funktioniert
|
||
- [ ] ACL wird respektiert (Test mit eingeschränktem User)
|
||
- [ ] Error Cases getestet (keine Permission, Entity nicht gefunden)
|
||
- [ ] Logs geprüft: Keine Errors
|
||
|
||
### Dokumentation
|
||
- [ ] Code-Kommentare hinzugefügt
|
||
- [ ] PHPDoc vollständig
|
||
- [ ] README aktualisiert (falls generische Änderungen)
|
||
|
||
---
|
||
|
||
## Support & Weiterentwicklung
|
||
|
||
### Erweiterungen des Templates
|
||
|
||
**Optional implementierbar:**
|
||
- Conditional Button Visibility (nur unter bestimmten Bedingungen)
|
||
- Batch Operations (mehrere Entities gleichzeitig)
|
||
- Background Jobs (lange Operationen asynchron)
|
||
- Email-Benachrichtigungen nach Erstellung
|
||
- PDF-Generierung
|
||
- Webhook-Trigger
|
||
- Audit Logging
|
||
|
||
### Performance-Optimierungen
|
||
|
||
Bei großen Datenmengen (> 100 Relations):
|
||
- Batch-Insert mit `saveEntity()` statt `createEntity()`
|
||
- Eager Loading mit `with()`
|
||
- Query Optimization mit `select()` und `where()`
|
||
- Background Jobs für lange Operationen
|
||
|
||
### Nützliche Links
|
||
|
||
- [EspoCRM Dokumentation](https://docs.espocrm.com)
|
||
- [GitHub Repository](https://github.com/espocrm/espocrm)
|
||
- [Community Forum](https://forum.espocrm.com)
|
||
- [API Dokumentation](https://docs.espocrm.com/development/api/)
|
||
|
||
---
|
||
|
||
## Versionierung
|
||
|
||
| Version | Datum | Änderungen |
|
||
|---------|-------|------------|
|
||
| 1.0 | 24.01.2026 | Initiale Version basierend auf Räumungsklage-Implementierung |
|
||
|
||
---
|
||
|
||
**Erstellt von:** EspoCRM Development Team
|
||
**Letzte Aktualisierung:** 24. Januar 2026
|
||
**Status:** Production Ready ✅
|