feat: Refactor CPuls entity and related resources; enhance localization, update layouts, and improve validation logic

This commit is contained in:
2026-02-13 10:28:21 +01:00
parent e1a963ffab
commit bf7eaa965f
12 changed files with 175 additions and 191 deletions

View File

@@ -1,121 +1,90 @@
<?php
namespace Espo\Custom\Api\CPuls;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\ORM\EntityManager;
class AbschliessenFuerTeam implements Action
{
public function __construct(
private \Espo\ORM\EntityManager $entityManager,
private \Espo\Core\Acl\Table $acl,
private \Espo\Entities\User $user,
private \Espo\Core\Utils\Log $log
private EntityManager $entityManager
) {}
public function process(Request $request): Response
{
$pulsId = $request->getRouteParam('id');
$data = $request->getParsedBody();
$teamId = $data->teamId ?? null;
if (!$pulsId || !$teamId) {
throw new BadRequest('pulsId oder teamId fehlt');
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest('ID fehlt.');
}
// 1. Validierung: Ist User in diesem Team?
$userTeams = $this->user->getLinkMultipleIdList('teams');
if (!in_array($teamId, $userTeams)) {
throw new Forbidden('User nicht in angegebenem Team');
}
// 2. Lade Puls
$puls = $this->entityManager->getEntity('CPuls', $pulsId);
$puls = $this->entityManager->getEntityById('CPuls', $id);
if (!$puls) {
throw new NotFound('Puls nicht gefunden');
throw new NotFound('Puls nicht gefunden.');
}
// 3. Validierung: syncStatus = clean?
if ($puls->get('syncStatus') !== 'clean') {
throw new BadRequest('Puls hat neue Dokumente (unclean) - bitte warten Sie auf die KI-Analyse');
// Prüfe, ob bereits finalisiert
if ($puls->get('status') === 'Finalisiert') {
throw new Forbidden('Puls wurde bereits finalisiert.');
}
// 4. Finde Zuordnung
$zuordnung = $this->entityManager
->getRDBRepository('CPulsTeamZuordnung')
// Prüfe syncStatus
if ($puls->get('syncStatus') === 'unclean') {
throw new BadRequest('Neue Dokumente vorhanden. Bitte warten Sie auf die KI-Analyse.');
}
// Hole aktuelle Team-ID des Benutzers
$user = $this->entityManager->getEntityById('User', $request->getServerParam('ESPO_USER_ID'));
if (!$user) {
throw new Forbidden('Benutzer nicht gefunden.');
}
$teamIds = $user->getLinkMultipleIdList('teams');
if (empty($teamIds)) {
throw new Forbidden('Sie sind keinem Team zugeordnet.');
}
// Finde Zuordnung für erstes Team des Benutzers
$zuordnung = $this->entityManager->getRDBRepositoryByClass('CPulsTeamZuordnung')
->where([
'pulsId' => $pulsId,
'teamId' => $teamId,
'pulsId' => $id,
'teamId' => $teamIds,
'aktiv' => true
])
->findOne();
if (!$zuordnung) {
throw new NotFound('Team-Zuordnung nicht gefunden oder nicht aktiv');
throw new Forbidden('Sie sind diesem Puls nicht zugeordnet oder nicht aktiv.');
}
// 5. Bereits abgeschlossen?
if ($zuordnung->get('abgeschlossen')) {
return Response::json([
'success' => true,
'message' => 'Bereits abgeschlossen',
'alreadyCompleted' => true
]);
}
// 6. Abschluss setzen
// Markiere Zuordnung als abgeschlossen
$zuordnung->set([
'abgeschlossen' => true,
'abgeschlossenAm' => date('Y-m-d H:i:s'),
'abgeschlossenVonId' => $this->user->getId()
'abgeschlossenVon' => $user->getId()
]);
$this->entityManager->saveEntity($zuordnung);
// 6.5. FIRST-READ-CLOSES: Finalisiere Block bei erstem Abschluss
if (!$puls->get('finalisiert')) {
$puls->set([
'finalisiert' => true,
'finalisierungsGrund' => 'Erstes Team',
'finalisiertAm' => date('Y-m-d H:i:s'),
'finalisiertVonId' => $this->user->getId()
]);
$this->log->info("Block finalisiert durch erstes Team (Team {$teamId}, User {$this->user->getId()})");
}
// 7. Prüfe: Alle Teams abgeschlossen?
$offeneTeams = $this->entityManager
->getRDBRepository('CPulsTeamZuordnung')
->where([
'pulsId' => $pulsId,
'aktiv' => true,
'abgeschlossen' => false
])
->count();
// 8. Update Puls-Status
if ($offeneTeams === 0) {
$puls->set('status', 'Abgeschlossen');
} else {
$puls->set('status', 'Teilweise abgeschlossen');
}
// FIRST-READ-CLOSES: Finalisiere den Puls sofort
$puls->set([
'status' => 'Finalisiert',
'finalisiertAm' => date('Y-m-d H:i:s'),
'finalisiertVon' => $user->getId()
]);
$this->entityManager->saveEntity($puls);
$this->log->info("Team {$teamId} hat Puls {$pulsId} abgeschlossen");
return Response::json([
$GLOBALS['log']->info("Puls {$id} durch Team {$zuordnung->get('teamId')} finalisiert (First-Read-Closes).");
return ResponseComposer::json([
'success' => true,
'status' => $puls->get('status'),
'finalisiert' => $puls->get('finalisiert'),
'offeneTeams' => $offeneTeams
'finalized' => true,
'message' => 'Puls wurde finalisiert (First-Read-Closes).'
]);
}
}

View File

@@ -2,6 +2,7 @@
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
@@ -10,7 +11,7 @@ class UpdateTeamStats implements BeforeSave
private \Espo\ORM\EntityManager $entityManager
) {}
public function beforeSave(Entity $entity, array $options): void
public function beforeSave(Entity $entity, SaveOptions $options): void
{
// Zähle Dokumente
if ($entity->isNew() || $entity->isAttributeChanged('id')) {

View File

@@ -1,37 +1,35 @@
{
"labels": {
"Create CPuls": "Puls erstellen",
"CPuls": "Puls",
"cPuls": "Pulse"
},
"fields": {
"name": "Bezeichnung",
"CPulse": "Pulse",
"Create CPuls": "Puls erstellen",
"name": "Name",
"status": "Status",
"syncStatus": "Synchronisations-Status",
"syncStatus": "Synchronisierungs-Status",
"kiAnalyse": "KI-Analyse",
"zusammenfassung": "Zusammenfassung",
"anzahlDokumente": "Anzahl Dokumente",
"anzahlTeamsAktiv": "Teams (aktiv)",
"anzahlTeamsAbgeschlossen": "Teams (abgeschlossen)",
"finalisiert": "Finalisiert",
"finalisierungsGrund": "Finalisierungsgrund",
"anzahlTeamsAktiv": "Teams aktiv",
"anzahlTeamsAbgeschlossen": "Teams abgeschlossen",
"finalisiertAm": "Finalisiert am",
"finalisiertVon": "Finalisiert von",
"mandantMitteilung": "Mitteilung an Mandant",
"mandantMitteilungText": "Mitteilungstext",
"parent": "Vorgang",
"dokumente": "Dokumente",
"teamZuordnungen": "Team-Zuordnungen"
"assignedUser": "Zugewiesen"
},
"links": {
"parent": "Vorgang",
"dokumente": "Dokumente",
"teamZuordnungen": "Team-Zuordnungen"
},
"tooltips": {
"syncStatus": "clean = KI-Analyse aktuell | unclean = Neue Dokumente, Analyse ausstehend",
"kiAnalyse": "Automatisch generierte Zusammenfassung durch KI-Middleware",
"zusammenfassung": "Kurze Zusammenfassung für Listen-Ansicht",
"finalisiert": "Block wurde geschlossen - neue Dokumente erzeugen automatisch einen neuen Block (First-Read-Closes Prinzip)",
"finalisierungsGrund": "Grund der Finalisierung: Erstes Team = Team hat abgeschlossen | Manuell = Admin-Aktion | Automatisch = System-Regel"
"syncStatus": "Status der KI-Analyse für neue Dokumente",
"kiAnalyse": "Vollständige Analyse der KI zu den Dokumenten",
"zusammenfassung": "Kurze Zusammenfassung der KI-Analyse",
"finalisiertAm": "Zeitpunkt, zu dem dieser Puls finalisiert wurde",
"finalisiertVon": "Benutzer, der den Puls durch Abschluss finalisiert hat",
"mandantMitteilung": "Soll eine Mitteilung an den Mandanten versendet werden?",
"mandantMitteilungText": "Text der Mitteilung an den Mandanten"
},
"options": {
"status": {
@@ -40,19 +38,12 @@
"Bereit": "Bereit",
"In Review": "In Review",
"Teilweise abgeschlossen": "Teilweise abgeschlossen",
"Abgeschlossen": "Abgeschlossen"
"Abgeschlossen": "Abgeschlossen",
"Finalisiert": "Finalisiert"
},
"syncStatus": {
"clean": "Aktuell",
"clean": "Synchron",
"unclean": "Ausstehend"
},
"finalisierungsGrund": {
"Erstes Team": "Erstes Team",
"Manuell": "Manuell",
"Automatisch": "Automatisch"
}
},
"presetFilters": {
"meineOffenen": "Meine offenen Pulse"
}
}

View File

@@ -1,58 +1,49 @@
{
"labels": {
"Create CPuls": "Create Pulse",
"CPuls": "Pulse",
"cPuls": "Pulses"
},
"fields": {
"CPulse": "Pulses",
"Create CPuls": "Create Pulse",
"name": "Name",
"status": "Status",
"syncStatus": "Sync Status",
"kiAnalyse": "AI Analysis",
"zusammenfassung": "Summary",
"anzahlDokumente": "Number of Documents",
"anzahlTeamsAktiv": "Teams (active)",
"anzahlTeamsAbgeschlossen": "Teams (completed)",
"finalisiert": "Finalized",
"finalisierungsGrund": "Finalization Reason",
"anzahlDokumente": "Document Count",
"anzahlTeamsAktiv": "Active Teams",
"anzahlTeamsAbgeschlossen": "Completed Teams",
"finalisiertAm": "Finalized At",
"finalisiertVon": "Finalized By",
"parent": "Parent Record",
"dokumente": "Documents",
"teamZuordnungen": "Team Assignments"
"mandantMitteilung": "Client Notification",
"mandantMitteilungText": "Notification Text",
"parent": "Case",
"assignedUser": "Assigned To"
},
"links": {
"parent": "Parent Record",
"dokumente": "Documents",
"teamZuordnungen": "Team Assignments"
},
"tooltips": {
"syncStatus": "clean = AI analysis up-to-date | unclean = New documents, analysis pending",
"kiAnalyse": "Automatically generated summary by AI middleware",
"zusammenfassung": "Short summary for list views",
"finalisiert": "Block has been closed - new documents will automatically create a new block (First-Read-Closes principle)",
"finalisierungsGrund": "Reason for finalization: First Team = Team completed | Manual = Admin action | Automatic = System rule"
"syncStatus": "AI analysis status for new documents",
"kiAnalyse": "Complete AI analysis of the documents",
"zusammenfassung": "Brief summary of the AI analysis",
"finalisiertAm": "Date and time when this pulse was finalized",
"finalisiertVon": "User who finalized the pulse by completion",
"mandantMitteilung": "Should a notification be sent to the client?",
"mandantMitteilungText": "Text of the notification to the client"
},
"options": {
"status": {
"Neu": "New",
"In Verarbeitung": "Processing",
"In Verarbeitung": "In Progress",
"Bereit": "Ready",
"In Review": "In Review",
"Teilweise abgeschlossen": "Partially Completed",
"Abgeschlossen": "Completed"
"Abgeschlossen": "Completed",
"Finalisiert": "Finalized"
},
"syncStatus": {
"clean": "Up-to-date",
"clean": "Synchronized",
"unclean": "Pending"
},
"finalisierungsGrund": {
"Erstes Team": "First Team",
"Manuell": "Manual",
"Automatisch": "Automatic"
}
},
"presetFilters": {
"meineOffenen": "My Open Pulses"
}
}

View File

@@ -1,17 +1,27 @@
{
"teamZuordnungen": {
"_delimiter_": {
"disabled": true
},
"activities": {
"disabled": true
},
"history": {
"disabled": true
},
"_tabBreak_0": {
"index": 0,
"sticked": true,
"style": "info",
"label": "Team-Zuordnungen"
"tabBreak": true,
"tabLabel": "Dokumente"
},
"dokumente": {
"index": 1,
"sticked": false,
"label": "Dokumente"
"index": 1
},
"_tabBreak_1": {
"index": 2,
"tabBreak": true,
"tabLabel": "Aktivitäten"
},
"stream": {
"index": 2,
"sticked": false
"index": 3
}
}

View File

@@ -0,0 +1,11 @@
[
{
"name": ":assignedUser"
},
{
"name": "teams"
},
{
"name": "teamZuordnungen"
}
]

View File

@@ -1,6 +1,8 @@
[
{
"label": "Übersicht",
"style": "default",
"tabBreak": false,
"rows": [
[
{"name": "name"},
@@ -14,10 +16,6 @@
{"name": "anzahlDokumente"},
{"name": "anzahlTeamsAktiv"}
],
[
{"name": "finalisiert"},
{"name": "finalisierungsGrund"}
],
[
{"name": "zusammenfassung", "span": 2}
]
@@ -25,6 +23,8 @@
},
{
"label": "KI-Analyse",
"style": "default",
"tabBreak": false,
"rows": [
[
{"name": "kiAnalyse", "span": 2}
@@ -32,7 +32,24 @@
]
},
{
"label": "System",
"label": "Mandant",
"style": "default",
"tabBreak": false,
"rows": [
[
{"name": "mandantMitteilung"},
{}
],
[
{"name": "mandantMitteilungText", "span": 2}
]
]
},
{
"label": "Metadaten",
"style": "default",
"tabBreak": true,
"tabLabel": "Erweitert",
"rows": [
[
{"name": "createdAt"},
@@ -41,6 +58,10 @@
[
{"name": "createdBy"},
{"name": "modifiedBy"}
],
[
{"name": "finalisiertAm"},
{"name": "finalisiertVon"}
]
]
}

View File

@@ -15,7 +15,8 @@
"Bereit",
"In Review",
"Teilweise abgeschlossen",
"Abgeschlossen"
"Abgeschlossen",
"Finalisiert"
],
"default": "Neu",
"required": true,
@@ -26,7 +27,8 @@
"Bereit": "success",
"In Review": "warning",
"Teilweise abgeschlossen": "info",
"Abgeschlossen": "success"
"Abgeschlossen": "success",
"Finalisiert": "danger"
}
},
"syncStatus": {
@@ -66,24 +68,6 @@
"notStorable": false,
"isCustom": true
},
"finalisiert": {
"type": "bool",
"default": false,
"readOnly": true,
"isCustom": true,
"tooltip": true
},
"finalisierungsGrund": {
"type": "enum",
"options": [
"Erstes Team",
"Manuell",
"Automatisch"
],
"readOnly": true,
"isCustom": true,
"tooltip": true
},
"finalisiertAm": {
"type": "datetime",
"readOnly": true,
@@ -95,6 +79,17 @@
"readOnly": true,
"isCustom": true
},
"mandantMitteilung": {
"type": "bool",
"default": false,
"isCustom": true,
"tooltip": true
},
"mandantMitteilungText": {
"type": "text",
"isCustom": true,
"tooltip": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
@@ -117,10 +112,6 @@
"type": "link",
"entity": "User",
"isCustom": true
},
"teams": {
"type": "linkMultiple",
"isCustom": true
}
},
"links": {
@@ -180,11 +171,8 @@
"syncStatus": {
"columns": ["syncStatus"]
},
"finalisiert": {
"columns": ["finalisiert"]
},
"createdAt": {
"columns": ["createdAt"]
}
}
}
}

View File

@@ -4,12 +4,14 @@
"type": "varchar",
"notStorable": true,
"select": {
"select": "CONCAT:(team.name, ' - ', puls.name)"
"select": "CONCAT:(team.name, ' - ', puls.name)",
"leftJoins": ["team", "puls"]
},
"orderBy": {
"order": [
["team.name", "{direction}"]
]
],
"leftJoins": ["team"]
}
},
"puls": {

View File

@@ -1,3 +1,3 @@
{
"beforeSaveApiScript": "// Verhindere Abschluss bei unclean Status\nif (\n (status == 'Abgeschlossen' || entity\\isAttributeChanged('status'))\n && syncStatus == 'unclean'\n) {\n recordService\\throwBadRequest('Puls kann nicht abgeschlossen werden: Neue Dokumente vorhanden (Status: unclean). Bitte warten Sie auf die KI-Analyse.');\n}\n\n// Verhindere Änderungen an finalisiertem Puls\nif (\n finalisiert == true\n && entity\\isAttributeChanged('finalisiert') == false\n && (entity\\isAttributeChanged('status') || entity\\isAttributeChanged('syncStatus'))\n) {\n recordService\\throwBadRequest('Puls ist finalisiert. Neue Dokumente erzeugen automatisch einen neuen Block.');\n}"
"beforeSaveApiScript": "// Verhindere Abschluss bei unclean Status\nif (\n entity\\isAttributeChanged('status') &&\n status == 'Abgeschlossen' &&\n syncStatus == 'unclean'\n) {\n recordService\\throwBadRequest('Puls kann nicht abgeschlossen werden: Neue Dokumente vorhanden (Status: unclean). Bitte warten Sie auf die KI-Analyse.');\n}\n\n// Verhindere Änderungen an finalisiertem Puls\nif (\n status == 'Finalisiert' &&\n !entity\\isAttributeChanged('status') &&\n (entity\\isAttributeChanged('syncStatus'))\n) {\n recordService\\throwBadRequest('Puls ist finalisiert. Neue Dokumente erzeugen automatisch einen neuen Block.');\n}"
}

View File

@@ -358,7 +358,7 @@ return [
0 => 'youtube.com',
1 => 'google.com'
],
'microtime' => 1770973606.273594,
'microtime' => 1770974863.576723,
'siteUrl' => 'https://crm.bitbylaw.com',
'fullTextSearchMinLength' => 4,
'webSocketUrl' => 'ws://api.bitbylaw.com:5000/espocrm/ws',

View File

@@ -1,7 +1,7 @@
<?php
return [
'cacheTimestamp' => 1770973606,
'microtimeState' => 1770973606.458758,
'cacheTimestamp' => 1770974863,
'microtimeState' => 1770974863.697017,
'currencyRates' => [
'EUR' => 1.0
],