fix: broaden task detection to include Contact-linked tasks

Tasks created with parentType='Contact' (e.g. preparation calls linked
to a case's contact) were invisible to AlertCalculator, CaseContextBuilder,
and OfficeContextBuilder — all of which only queried parentType='Case'.

This caused false "no preparation task" alerts in Shira's daily standup
even when preparation tasks existed, because they were linked to the
case's Contact instead of the Case entity directly.

Changes:
- AlertCalculator: getSummary, findOverdueTasks, findUpcomingTasks,
  getWorkloadByUser now query parentType=['Case','Contact']
- findUpcomingHearingsWithoutPrep: new caseHasPreparation() helper
  checks Case tasks, Contact tasks, and NhActivity-linked tasks
- CaseContextBuilder.getOpenTasks: OR query across Case + Contact IDs
- OfficeContextBuilder.buildCaseDrillDown: same OR query pattern
- All three classes gain a getCaseContactIds() helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 06:02:27 +00:00
parent 289a9cf27f
commit 2569003f33
3 changed files with 172 additions and 14 deletions
@@ -46,7 +46,7 @@ class AlertCalculator
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count(); ->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task') $totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count(); ->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
$upcomingHearings = $this->entityManager->getRDBRepository('Case') $upcomingHearings = $this->entityManager->getRDBRepository('Case')
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count(); ->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
@@ -106,7 +106,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task') $tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName']) ->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case']) ->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 50)->find(); ->order('dateEnd', 'ASC')->limit(0, 50)->find();
foreach ($tasks as $task) { foreach ($tasks as $task) {
@@ -132,7 +132,7 @@ class AlertCalculator
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days')); $recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
$cases = $this->entityManager->getRDBRepository('Case') $cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt']) ->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false]) ->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('cNextHearing', 'ASC')->find(); ->order('cNextHearing', 'ASC')->find();
@@ -140,13 +140,7 @@ class AlertCalculator
$lastActivity = $case->get('cLastActivityAt'); $lastActivity = $case->get('cLastActivityAt');
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue; if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
$openTaskCount = $this->entityManager->getRDBRepository('Task') if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
->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; $daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$severity = ($daysUntil <= 1) ? 'critical' : 'warning'; $severity = ($daysUntil <= 1) ? 'critical' : 'warning';
@@ -162,6 +156,104 @@ class AlertCalculator
return $alerts; return $alerts;
} }
/**
* Check if a case has any evidence of hearing preparation:
* 1. Tasks linked directly to the case (parentType='Case')
* 2. Tasks linked to the case's contacts (parentType='Contact')
* 3. Tasks linked through NhActivity records for this case
*/
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
{
$caseId = $case->get('id');
// Collect all parentId+parentType pairs that relate to this case
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
// Also check tasks linked to the case's contacts
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
// Check 1: Any open tasks linked to the case or its contacts
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status!=' => ['Completed', 'Canceled', 'Deferred'],
'deleted' => false,
])->count();
if ($openTaskCount > 0) return true;
// Check 2: Any recently completed tasks linked to the case or its contacts
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status' => 'Completed',
'modifiedAt>=' => $recentTaskThreshold,
'deleted' => false,
])->count();
if ($recentCompleted > 0) return true;
// Check 3: Tasks linked through NhActivity records for this case
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->count();
if ($nhActivityTaskCount > 0) {
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
->select(['taskId'])
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->find();
$taskIds = [];
foreach ($nhActivities as $nha) {
$taskIds[] = $nha->get('taskId');
}
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
->where([
'id' => $taskIds,
'status!=' => ['Canceled'],
'deleted' => false,
])->count();
if ($activeNhTasks > 0) return true;
}
return false;
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function findUnassignedNewCases(): array private function findUnassignedNewCases(): array
{ {
$alerts = []; $alerts = [];
@@ -191,7 +283,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task') $tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName']) ->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case']) ->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 30)->find(); ->order('dateEnd', 'ASC')->limit(0, 30)->find();
foreach ($tasks as $task) { foreach ($tasks as $task) {
@@ -237,7 +329,7 @@ class AlertCalculator
foreach (array_keys($userCounts) as $uid) { foreach (array_keys($userCounts) as $uid) {
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task') $userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count(); ->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
} }
return array_values($userCounts); return array_values($userCounts);
@@ -82,9 +82,18 @@ class CaseContextBuilder
private function getOpenTasks(string $caseId): array private function getOpenTasks(string $caseId): array
{ {
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($caseId);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = []; $tasks = [];
$collection = $this->entityManager->getRDBRepository('Task') $collection = $this->entityManager->getRDBRepository('Task')
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']]) ->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
->order('dateEnd', 'ASC')->limit(0, 10)->find(); ->order('dateEnd', 'ASC')->limit(0, 10)->find();
foreach ($collection as $t) { foreach ($collection as $t) {
@@ -97,6 +106,32 @@ class CaseContextBuilder
return $tasks; return $tasks;
} }
private function getCaseContactIds(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return [];
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function getUpcomingMeetings(string $caseId): array private function getUpcomingMeetings(string $caseId): array
{ {
$meetings = []; $meetings = [];
@@ -58,10 +58,18 @@ class OfficeContextBuilder
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')]; $contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
} }
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = []; $tasks = [];
foreach ($this->entityManager->getRDBRepository('Task') foreach ($this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName']) ->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false]) ->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) { ->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')]; $tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
} }
@@ -76,4 +84,27 @@ class OfficeContextBuilder
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes]; return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
} }
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
} }