feat(treatment): per-case operators, shown on the list and searchable

A treatment case said which doctor supervised it but never who actually did the
work, so the list could not answer the first question a manager asks about a
course: who performed it.

Two separate things now travel with the case. `performed_by` is history —
derived from the sessions' performedBy, so it only ever reports what happened.
`assigned_staff` is plan — a new treatment_case_staff table, editable from the
modal, saying who is meant to handle this patient's course. The card shows the
first and falls back to the second while nothing has been performed yet.

Search matches both. A manager typing an operator's name wants that person's
work, and work already done is part of it.

Assignment also narrows the operator queue: a case with assigned staff shows its
sessions only to those people, because a patient who started a multi-session
course with one operator should keep them. An unassigned case keeps the existing
protocol rule, and an empty list means "anyone the protocol allows" rather than
"nobody" — the same "no rows is not a restriction" convention used elsewhere.

Unlike areas, removing an operator erases nothing: a finished session carries its
real operator on itself and never consults this list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 14:55:01 +03:30
co-authored by Claude Opus 5
parent 12c0d57e1a
commit b78f7311cf
13 changed files with 416 additions and 6 deletions
+3
View File
@@ -122,6 +122,9 @@ final class GlobalTables
// درخواست نمی‌آید. جلسه و رکورد ناحیه برعکس‌اند — پنل پرسنل uuidشان را مستقیم
// می‌فرستد — پس آن دو جفت محیط خودشان را دارند، نه اینجا.
\App\Treatment\Entity\TreatmentCaseArea::class => \App\Treatment\Entity\TreatmentCase::class,
// اپراتورهای اختصاص‌یافته هم همین‌طور: فقط از خودِ پرونده پیمایش می‌شوند و
// درخواست هرگز uuid این ردیف را نمی‌فرستد — `staff_uuids` می‌فرستد.
\App\Treatment\Entity\TreatmentCaseStaff::class => \App\Treatment\Entity\TreatmentCase::class,
// یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال
// در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند.
\App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class,
+51
View File
@@ -87,6 +87,10 @@ class TreatmentCase
#[ORM\OrderBy(['sessionNumber' => 'ASC'])]
private Collection $sessions;
/** اپراتورهای اختصاص‌یافته به این پرونده. خالی یعنی «هر کسی که پروتکل مجاز دانسته». */
#[ORM\OneToMany(targetEntity: TreatmentCaseStaff::class, mappedBy: 'treatmentCase', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $assignedStaff;
public function __construct(
string $entityType,
int $entityId,
@@ -106,6 +110,7 @@ class TreatmentCase
$this->updatedAt = time();
$this->areas = new ArrayCollection();
$this->sessions = new ArrayCollection();
$this->assignedStaff = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
@@ -120,6 +125,7 @@ class TreatmentCase
public function getClosedAt(): ?int { return $this->closedAt; }
public function getAreas(): Collection { return $this->areas; }
public function getSessions(): Collection { return $this->sessions; }
public function getAssignedStaff(): Collection { return $this->assignedStaff; }
public function addArea(TreatmentCaseArea $area): self
{
@@ -195,6 +201,22 @@ class TreatmentCase
return $this;
}
/**
* @param list<TreatmentCaseStaff> $staff
*/
public function replaceAssignedStaff(array $staff): self
{
$this->assignedStaff->clear();
foreach ($staff as $row) {
$this->assignedStaff->add($row);
}
$this->touch();
return $this;
}
public function removeSession(TreatmentSession $session): self
{
$this->sessions->removeElement($session);
@@ -234,6 +256,15 @@ class TreatmentCase
static fn (TreatmentCaseArea $a): array => $a->toArray(),
$this->areas->toArray(),
),
'assigned_staff' => array_values(array_map(
static fn (TreatmentCaseStaff $s): array => $s->toArray(),
$this->assignedStaff->toArray(),
)),
/**
* چه کسی واقعاً انجامش داده — از جلسات، نه از اختصاص. اختصاص برنامه است
* و این سابقه؛ فهرست پرونده‌ها باید دومی را نشان بدهد.
*/
'performed_by' => array_values($this->performers()),
];
if ($withSessions) {
@@ -246,5 +277,25 @@ class TreatmentCase
return $data;
}
/**
* اپراتورهای یکتایی که جلسه‌ای از این پرونده را انجام داده‌اند.
*
* @return array<string, array{uuid: string, name: string}>
*/
private function performers(): array
{
$out = [];
foreach ($this->sessions as $session) {
$staff = $session->getPerformedBy();
if ($staff !== null) {
$out[$staff->getUuid()] = ['uuid' => $staff->getUuid(), 'name' => $staff->getFullName()];
}
}
return $out;
}
private function touch(): void { $this->updatedAt = time(); }
}
@@ -0,0 +1,53 @@
<?php
namespace App\Treatment\Entity;
use App\Staff\Entity\ClinicStaff;
use Doctrine\ORM\Mapping as ORM;
/**
* اپراتورِ اختصاص‌یافته به یک پروندهٔ درمان.
*
* جدا از `TreatmentProtocolStaff` است و باید هم باشد: پروتکل می‌گوید «چه کسانی
* *مجازند* این سرویس را انجام دهند» و این می‌گوید «چه کسی *این بیمار* را انجام
* می‌دهد». بیمار معمولاً دوست دارد دورهٔ چندجلسه‌ایش را یک نفر تمام کند.
*
* پروندهٔ بدون اختصاص یعنی «هر کسی که پروتکل مجاز دانسته» — همان قاعدهٔ
* «نبودِ رکورد محدودیت نیست» که در صفِ جلسات هم هست.
*/
#[ORM\Entity]
#[ORM\Table(name: 'treatment_case_staff')]
#[ORM\UniqueConstraint(name: 'uq_case_staff', columns: ['treatment_case_id', 'staff_id'])]
class TreatmentCaseStaff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: TreatmentCase::class, inversedBy: 'assignedStaff')]
#[ORM\JoinColumn(name: 'treatment_case_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentCase $treatmentCase;
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ClinicStaff $staff;
public function __construct(TreatmentCase $case, ClinicStaff $staff)
{
$this->treatmentCase = $case;
$this->staff = $staff;
}
public function getId(): ?int { return $this->id; }
public function getTreatmentCase(): TreatmentCase { return $this->treatmentCase; }
public function getStaff(): ClinicStaff { return $this->staff; }
public function toArray(): array
{
return [
'uuid' => $this->staff->getUuid(),
'name' => $this->staff->getFullName(),
];
}
}
@@ -74,12 +74,34 @@ class TreatmentCaseRepository extends ServiceEntityRepository
}
if ($q !== null && $q !== '') {
/**
* پرسنل از دو راه می‌آید: کسی که به پرونده اختصاص یافته، و کسی که واقعاً
* جلسه‌ای از آن را انجام داده. مدیر که نام اپراتور را می‌زند هر دو را
* می‌خواهد — «کارهای این نفر» شامل کارِ انجام‌شده هم هست.
*/
$assignedStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentCaseStaff cs
JOIN cs.staff cst
WHERE cs.treatmentCase = c AND cst.fullName LIKE :q
)
DQL;
$performingStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentSession ts
JOIN ts.performedBy tsp
WHERE ts.treatmentCase = c AND tsp.fullName LIKE :q
)
DQL;
$qb->join('c.patientRecord', 'pr')
->join('pr.user', 'u')
->join('c.serviceItem', 'si')
->andWhere(
'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q'
. ' OR pr.recordNumber LIKE :q OR si.name LIKE :q',
. ' OR pr.recordNumber LIKE :q OR si.name LIKE :q'
. ' OR ' . $assignedStaff . ' OR ' . $performingStaff,
)
->setParameter('q', '%' . $q . '%');
}
@@ -119,6 +119,20 @@ class TreatmentSessionRepository extends ServiceEntityRepository
)
DQL;
$assignedToThisStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentCaseStaff cs
WHERE cs.treatmentCase = c AND cs.staff = :staff
)
DQL;
$caseNamesAnyStaff = <<<'DQL'
EXISTS (
SELECT 1 FROM App\Treatment\Entity\TreatmentCaseStaff cs2
WHERE cs2.treatmentCase = c
)
DQL;
return $this->createQueryBuilder('s')
->join('s.appointment', 'a')
->join('s.treatmentCase', 'c')
@@ -127,8 +141,16 @@ class TreatmentSessionRepository extends ServiceEntityRepository
// محیط، وگرنه پرسنلِ یک کلینیک جلسات کلینیک دیگر را می‌بیند.
->andWhere('s.entityType = :type')
->andWhere('s.entityId = :id')
/**
* پروندهٔ اختصاص‌یافته صفِ خودش را دارد: بیمار دوره‌اش را با یک نفر شروع
* کرده و جلسات بعدی نباید در صفِ بقیه ظاهر شوند. پروندهٔ بی‌اختصاص همان
* قاعدهٔ پروتکل را دارد.
*/
->andWhere(sprintf(
's.performedBy = :staff OR a.staff = :staff OR (s.performedBy IS NULL AND a.staff IS NULL AND (%s OR NOT %s))',
's.performedBy = :staff OR a.staff = :staff'
. ' OR (s.performedBy IS NULL AND a.staff IS NULL AND (%s OR (NOT %s AND (%s OR NOT %s))))',
$assignedToThisStaff,
$caseNamesAnyStaff,
$allowsThisStaff,
$namesAnyStaff,
))
@@ -8,7 +8,9 @@ use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Treatment\Entity\TreatmentCase;
use App\Staff\Repository\ClinicStaffRepository;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentCaseStaff;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentSession;
use Doctrine\ORM\EntityManagerInterface;
@@ -27,6 +29,7 @@ final class TreatmentCaseEditor
public function __construct(
private readonly DoctorRepository $doctors,
private readonly CatalogCategoryRepository $categories,
private readonly ClinicStaffRepository $staff,
private readonly EntityManagerInterface $em,
) {}
@@ -47,6 +50,10 @@ final class TreatmentCaseEditor
$this->applyAreas($case, $data['area_uuids']);
}
if (array_key_exists('staff_uuids', $data)) {
$this->applyStaff($case, $data['staff_uuids']);
}
if (array_key_exists('total_sessions', $data)) {
$this->applyTotalSessions($case, (int) $data['total_sessions']);
}
@@ -220,6 +227,44 @@ final class TreatmentCaseEditor
$case->setTotalSessions($total);
}
/**
* اپراتورهای اختصاص‌یافته — جایگزین کامل، و فهرست خالی مجاز است.
*
* خالی یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس». برخلاف نواحی، اینجا
* حذف چیزی از سابقه پاک نمی‌کند: جلسهٔ انجام‌شده اپراتور واقعی‌اش را روی خودش
* دارد (`performedBy`) و به این فهرست نگاه نمی‌کند.
*
* @param mixed $uuids فهرست uuid پرسنل
*/
private function applyStaff(TreatmentCase $case, mixed $uuids): void
{
if (!is_array($uuids)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فهرست پرسنل نامعتبر است', 422, 'staff_uuids');
}
$rows = [];
foreach (array_unique(array_map('strval', $uuids)) as $uuid) {
$staff = $this->staff->findOneBy(['uuid' => $uuid, 'active' => true]);
if ($staff === null
|| $staff->getEntityType() !== $case->getEntityType()
|| $staff->getEntityId() !== $case->getEntityId()
) {
throw new AppException(
ErrorCodes::ERR_NOT_FOUND_001,
'پرسنل یافت نشد، غیرفعال است، یا مال محیط دیگری است',
404,
'staff_uuids',
);
}
$rows[] = new TreatmentCaseStaff($case, $staff);
}
$case->replaceAssignedStaff($rows);
}
private function areaHasRecords(TreatmentCaseArea $area): bool
{
return (int) $this->em->createQuery(