Update cache timestamps in config; add README for custom actions implementation
This commit is contained in:
974
custom/README.md
Normal file
974
custom/README.md
Normal file
@@ -0,0 +1,974 @@
|
||||
# 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 ✅
|
||||
@@ -359,8 +359,8 @@ return [
|
||||
0 => 'youtube.com',
|
||||
1 => 'google.com'
|
||||
],
|
||||
'cacheTimestamp' => 1769249126,
|
||||
'microtime' => 1769249126.108164,
|
||||
'cacheTimestamp' => 1769252191,
|
||||
'microtime' => 1769252191.810945,
|
||||
'siteUrl' => 'https://crm.bitbylaw.com',
|
||||
'fullTextSearchMinLength' => 4,
|
||||
'appTimestamp' => 1768843902,
|
||||
|
||||
Reference in New Issue
Block a user