# EspoCRM Best Practices & Entwicklungsrichtlinien

**Version:** 2.1  
**Datum:** 9. März 2026  
**Zielgruppe:** AI Code Agents & Entwickler

---

## 📋 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

### 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!

### 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

### 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

**Haupt-Tool:** `custom/scripts/validate_and_rebuild.py`

```bash
# Vollständige Validierung + Rebuild
python3 custom/scripts/validate_and_rebuild.py

# Nur Validierung (kein Rebuild)
python3 custom/scripts/validate_and_rebuild.py --dry-run

# Mit E2E Tests überspringen
python3 custom/scripts/validate_and_rebuild.py --skip-e2e
```

**Das Tool prüft:**
1. ✅ JSON-Syntax aller Custom-Dateien
2. ✅ Relationship-Konsistenz (bidirektionale Links)
3. ✅ Formula-Script Platzierung
4. ✅ i18n-Vollständigkeit (de_DE + en_US)
5. ✅ Layout-Struktur (bottomPanelsDetail, detail.json)
6. ✅ Dateirechte (www-data:www-data)
7. ✅ CSS-Validierung (csslint)
8. ✅ JavaScript-Validierung (jshint)
9. ✅ PHP-Syntax (php -l)
10. ✅ EspoCRM Rebuild
11. ✅ E2E-Tests (CRUD-Operationen)

**Bei Fehlern:** Automatische Fehlerlog-Analyse der letzten 50 Log-Zeilen!

### 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?

### 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.
