This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
SmartAssistant/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php
T
chaim f616065585 fix: dashlet detail shows actual items instead of empty alerts
Cards "תיקים פתוחים" and "דיונים השבוע" showed counts but clicking
revealed "אין פריטים להצגה" because card counts came from direct DB
queries while detail filtered from alerts with stricter criteria.

Added getOpenCaseItems() and getUpcomingHearingItems() to AlertCalculator,
included them in summary API response, and wired dashlet to use them.

Also: renamed extension to SmartAssistant, title to "שירה — עוזרת אישית".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:25:31 +00:00

339 lines
15 KiB
PHP

<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Acl;
use Espo\ORM\EntityManager;
class AlertCalculator
{
private EntityManager $entityManager;
private Acl $acl;
private const CLOSED_STATUSES = ['ClosedSettlement', 'ClosedJudgment', 'Closed', 'Rejected'];
public function __construct(EntityManager $entityManager, Acl $acl)
{
$this->entityManager = $entityManager;
$this->acl = $acl;
}
public function calculateAlerts(array $thresholds = []): array
{
$inactivityWarningDays = $thresholds['inactivityWarningDays'] ?? 14;
$inactivityCriticalDays = $thresholds['inactivityCriticalDays'] ?? 30;
$upcomingHearingDays = $thresholds['upcomingHearingDays'] ?? 7;
$alerts = [];
$alerts = array_merge($alerts, $this->findInactiveCases($inactivityWarningDays, $inactivityCriticalDays));
$alerts = array_merge($alerts, $this->findOverdueTasks());
$alerts = array_merge($alerts, $this->findUpcomingHearingsWithoutPrep($upcomingHearingDays));
$alerts = array_merge($alerts, $this->findUnassignedNewCases());
$alerts = array_merge($alerts, $this->findUpcomingTasks());
$severityOrder = ['critical' => 0, 'warning' => 1, 'info' => 2];
usort($alerts, fn($a, $b) => ($severityOrder[$a['severity']] ?? 3) <=> ($severityOrder[$b['severity']] ?? 3));
return $alerts;
}
public function getSummary(): array
{
$today = date('Y-m-d');
$weekEnd = date('Y-m-d', strtotime('+7 days'));
$totalOpenCases = $this->entityManager->getRDBRepository('Case')
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
$alerts = $this->calculateAlerts();
$criticalCount = count(array_filter($alerts, fn($a) => $a['severity'] === 'critical'));
$warningCount = count(array_filter($alerts, fn($a) => $a['severity'] === 'warning'));
return [
'totalOpenCases' => $totalOpenCases,
'totalOverdueTasks' => $totalOverdueTasks,
'upcomingHearings7d' => $upcomingHearings,
'casesNeedingAttention' => $criticalCount + $warningCount,
'criticalAlerts' => $criticalCount,
'warningAlerts' => $warningCount,
];
}
private function findInactiveCases(int $warningDays, int $criticalDays): array
{
$alerts = [];
$now = new \DateTime();
$warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s');
$criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s');
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
->where([
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
'OR' => [
['cLastActivityAt<' => $warningThreshold, 'cLastActivityAt!=' => null],
['cLastActivityAt' => null, 'createdAt<' => $warningThreshold],
],
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
foreach ($cases as $case) {
$lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt');
if (!$lastActivity) continue;
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
$severity = ($lastActivity < $criticalThreshold) ? 'critical' : 'warning';
$alerts[] = [
'type' => 'inactive_case', 'severity' => $severity,
'caseId' => $case->get('id'), 'caseName' => $case->get('name'),
'assignedUser' => $case->get('assignedUserName'),
'daysSinceActivity' => $daysSince,
'message' => $case->get('name') . ' - ' . $daysSince . ' ימים ללא פעילות',
];
}
return $alerts;
}
private function findOverdueTasks(): array
{
$alerts = [];
$today = date('Y-m-d');
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
->order('dateEnd', 'ASC')->limit(0, 50)->find();
foreach ($tasks as $task) {
$daysOverdue = (new \DateTime())->diff(new \DateTime($task->get('dateEnd')))->days;
$alerts[] = [
'type' => 'overdue_task', 'severity' => 'critical',
'taskId' => $task->get('id'), 'taskName' => $task->get('name'),
'caseId' => $task->get('parentId'), 'caseName' => $task->get('parentName'),
'assignedUser' => $task->get('assignedUserName'),
'daysOverdue' => $daysOverdue,
'message' => $task->get('parentName') . ' - משימה "' . $task->get('name') . '" באיחור ' . $daysOverdue . ' ימים',
];
}
return $alerts;
}
private function findUpcomingHearingsWithoutPrep(int $hearingDays): array
{
$alerts = [];
$today = date('Y-m-d');
$threshold = date('Y-m-d', strtotime("+{$hearingDays} days"));
$recentActivityThreshold = date('Y-m-d H:i:s', strtotime('-3 days'));
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('cNextHearing', 'ASC')->find();
foreach ($cases as $case) {
$lastActivity = $case->get('cLastActivityAt');
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
if ($openTaskCount > 0) continue;
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
if ($recentCompleted > 0) continue;
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
$alerts[] = [
'type' => 'hearing_no_prep', 'severity' => $severity,
'caseId' => $case->get('id'), 'caseName' => $case->get('name'),
'assignedUser' => $case->get('assignedUserName'),
'hearingDate' => $case->get('cNextHearing'), 'daysUntil' => $daysUntil,
'message' => $case->get('name') . ' - דיון בעוד ' . $daysUntil . ' ימים, אין משימת הכנה',
];
}
return $alerts;
}
private function findUnassignedNewCases(): array
{
$alerts = [];
$threshold = date('Y-m-d H:i:s', strtotime('-3 days'));
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'createdAt'])->where(['status' => 'New', 'createdAt<' => $threshold, 'deleted' => false])
->order('createdAt', 'ASC')->limit(0, 20)->find();
foreach ($cases as $case) {
$daysSince = (new \DateTime())->diff(new \DateTime($case->get('createdAt')))->days;
$alerts[] = [
'type' => 'unassigned_case', 'severity' => 'warning',
'caseId' => $case->get('id'), 'caseName' => $case->get('name'),
'daysSinceCreated' => $daysSince,
'message' => $case->get('name') . ' - תיק חדש לא שובץ (' . $daysSince . ' ימים)',
];
}
return $alerts;
}
private function findUpcomingTasks(): array
{
$alerts = [];
$today = date('Y-m-d');
$threshold = date('Y-m-d', strtotime('+3 days'));
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
->order('dateEnd', 'ASC')->limit(0, 30)->find();
foreach ($tasks as $task) {
$daysUntil = (new \DateTime())->diff(new \DateTime($task->get('dateEnd')))->days;
$alerts[] = [
'type' => 'upcoming_task', 'severity' => 'info',
'taskId' => $task->get('id'), 'taskName' => $task->get('name'),
'caseId' => $task->get('parentId'), 'caseName' => $task->get('parentName'),
'assignedUser' => $task->get('assignedUserName'),
'daysUntil' => $daysUntil,
'message' => $task->get('parentName') . ' - משימה "' . $task->get('name') . '" בעוד ' . $daysUntil . ' ימים',
];
}
return $alerts;
}
public function getCasesByStatus(): array
{
$result = [];
$statuses = ['New', 'Assigned', 'Pending', 'PendingHearing', 'PendingDecision', 'PendingResponse', 'Negotiation', 'Suspended', 'ClosedSettlement', 'ClosedJudgment', 'Closed', 'Rejected'];
foreach ($statuses as $status) {
$count = $this->entityManager->getRDBRepository('Case')->where(['status' => $status, 'deleted' => false])->count();
if ($count > 0) $result[$status] = $count;
}
return $result;
}
public function getWorkloadByUser(): array
{
$userCounts = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['assignedUserId', 'assignedUserName'])
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false, 'assignedUserId!=' => null])->find();
foreach ($cases as $case) {
$uid = $case->get('assignedUserId');
if (!isset($userCounts[$uid])) {
$userCounts[$uid] = ['userId' => $uid, 'userName' => $case->get('assignedUserName'), 'openCases' => 0, 'openTasks' => 0];
}
$userCounts[$uid]['openCases']++;
}
foreach (array_keys($userCounts) as $uid) {
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
}
return array_values($userCounts);
}
public function getOpenCaseItems(int $limit = 30): array
{
$items = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('createdAt', 'DESC')
->limit(0, $limit)
->find();
$statusLabels = [
'New' => 'חדש', 'Assigned' => 'שובץ', 'Pending' => 'ממתין',
'PendingHearing' => 'ממתין לדיון', 'PendingDecision' => 'ממתין להחלטה',
'PendingResponse' => 'ממתין לתגובה', 'Negotiation' => 'משא ומתן', 'Suspended' => 'מושהה',
];
foreach ($cases as $case) {
$status = $case->get('status');
$label = $statusLabels[$status] ?? $status;
$assigned = $case->get('assignedUserName');
$items[] = [
'type' => 'open_case',
'severity' => 'info',
'caseId' => $case->get('id'),
'caseName' => $case->get('name'),
'message' => $case->get('name') . ' — ' . $label . ($assigned ? ' (' . $assigned . ')' : ''),
];
}
return $items;
}
public function getUpcomingHearingItems(int $days = 7): array
{
$today = date('Y-m-d');
$threshold = date('Y-m-d', strtotime("+{$days} days"));
$items = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'cCourt', 'assignedUserName'])
->where([
'cNextHearing>=' => $today,
'cNextHearing<=' => $threshold,
'status!=' => self::CLOSED_STATUSES,
'deleted' => false,
])
->order('cNextHearing', 'ASC')
->find();
foreach ($cases as $case) {
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$court = $case->get('cCourt');
$severity = ($daysUntil <= 1) ? 'warning' : 'info';
$msg = $case->get('name') . ' — דיון בעוד ' . $daysUntil . ' ימים';
if ($court) {
$msg .= ' (' . $court . ')';
}
$items[] = [
'type' => 'upcoming_hearing',
'severity' => $severity,
'caseId' => $case->get('id'),
'caseName' => $case->get('name'),
'hearingDate' => $case->get('cNextHearing'),
'daysUntil' => $daysUntil,
'message' => $msg,
];
}
return $items;
}
public function getUpcomingHearings(int $days = 7): array
{
$today = date('Y-m-d');
$threshold = date('Y-m-d', strtotime("+{$days} days"));
$result = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'cCourt', 'cJudge', 'assignedUserName', 'status'])
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('cNextHearing', 'ASC')->find();
foreach ($cases as $case) {
$result[] = [
'caseId' => $case->get('id'), 'caseName' => $case->get('name'),
'hearingDate' => $case->get('cNextHearing'), 'court' => $case->get('cCourt'),
'judge' => $case->get('cJudge'), 'assignedUser' => $case->get('assignedUserName'),
];
}
return $result;
}
}