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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user