From b78f7311cfd82dca0cc5f92b8d2190c7c36b09ad Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 14:55:01 +0330 Subject: [PATCH] feat(treatment): per-case operators, shown on the list and searchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/TreatmentCaseEditModal.tsx | 57 ++++++++++++- .../admin/pages/TreatmentCasesPage.test.tsx | 20 +++++ assets/admin/pages/TreatmentCasesPage.tsx | 11 ++- assets/admin/types/index.ts | 4 + docs/api/treatment.md | 15 +++- migrations/Version20260807111228.php | 35 ++++++++ src/Shared/Tenant/GlobalTables.php | 3 + src/Treatment/Entity/TreatmentCase.php | 51 ++++++++++++ src/Treatment/Entity/TreatmentCaseStaff.php | 53 ++++++++++++ .../Repository/TreatmentCaseRepository.php | 24 +++++- .../Repository/TreatmentSessionRepository.php | 24 +++++- src/Treatment/Service/TreatmentCaseEditor.php | 45 +++++++++++ tests/Treatment/TreatmentCaseEditTest.php | 80 +++++++++++++++++++ 13 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 migrations/Version20260807111228.php create mode 100644 src/Treatment/Entity/TreatmentCaseStaff.php diff --git a/assets/admin/components/TreatmentCaseEditModal.tsx b/assets/admin/components/TreatmentCaseEditModal.tsx index bcfeb4e2..a499eac2 100644 --- a/assets/admin/components/TreatmentCaseEditModal.tsx +++ b/assets/admin/components/TreatmentCaseEditModal.tsx @@ -16,6 +16,7 @@ const STATUS_OPTIONS: Array<{ value: TreatmentCaseStatus; label: string }> = [ ]; interface DoctorRow { uuid: string; name?: string | null; full_name?: string | null } +interface StaffRow { uuid: string; full_name: string } /** * ویرایش پروندهٔ درمان. @@ -43,6 +44,12 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: { staleTime: 60_000, }); + const staffQ = useQuery>({ + queryKey: ['staff-list'], + queryFn: () => api.get('/api/v1/staff'), + staleTime: 60_000, + }); + const detail = data?.data; return ( @@ -59,6 +66,7 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: { detail={detail} doctors={doctorsQ.data?.data?.data ?? []} doctorsLoading={doctorsQ.isLoading} + staff={staffQ.data?.data ?? []} onSaved={() => { qc.invalidateQueries({ queryKey: ['treatment-cases'] }); qc.invalidateQueries({ queryKey: ['treatment-case', caseUuid] }); @@ -71,10 +79,11 @@ export default function TreatmentCaseEditModal({ caseUuid, onClose }: { ); } -function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: { +function EditForm({ detail, doctors, doctorsLoading, staff, onSaved, onClose }: { detail: TreatmentCaseDetail; doctors: DoctorRow[]; doctorsLoading: boolean; + staff: StaffRow[]; onSaved: () => void; onClose: () => void; }) { @@ -84,6 +93,7 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: { const [areas, setAreas] = useState( detail.areas.map((a) => a.category_uuid).filter((u): u is string => u !== null), ); + const [staffUuids, setStaffUuids] = useState(detail.assigned_staff.map((s) => s.uuid)); // ناحیه‌ای که دسته‌اش حذف شده در سابقه هست ولی دیگر قابل انتخاب نیست — باید دیده // شود، وگرنه کاربر فکر می‌کند فرم آن را انداخته است. @@ -99,6 +109,7 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: { status, supervisor_doctor_uuid: supervisor, area_uuids: areas, + staff_uuids: staffUuids, total_sessions: Number(total) || 0, }), onSuccess: () => { toast.success('پرونده به‌روزرسانی شد'); onSaved(); }, @@ -107,6 +118,8 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: { const toggleArea = (uuid: string) => setAreas((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]); + const toggleStaff = (uuid: string) => + setStaffUuids((prev) => prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]); const totalValid = Number(total) >= 2 && Number(total) <= 60; const valid = areas.length > 0 && totalValid; @@ -193,6 +206,48 @@ function EditForm({ detail, doctors, doctorsLoading, onSaved, onClose }: { )} +
+ + {staff.length === 0 ? ( + پرسنلی در این محیط تعریف نشده است. + ) : ( +
+ {staff.map((p) => { + const on = staffUuids.includes(p.uuid); + return ( + + ); + })} +
+ )} + + {staffUuids.length === 0 + ? 'خالی یعنی هر پرسنلِ مجازِ این سرویس می‌تواند جلسات را انجام دهد.' + : 'جلسات این پرونده فقط در صف همین افراد دیده می‌شود.'} + + {detail.performed_by.length > 0 && ( + + تا اینجا انجام‌دهنده: {detail.performed_by.map((p) => p.name).join('، ')} + + )} +
+
diff --git a/assets/admin/pages/TreatmentCasesPage.test.tsx b/assets/admin/pages/TreatmentCasesPage.test.tsx index f7c71086..4479b209 100644 --- a/assets/admin/pages/TreatmentCasesPage.test.tsx +++ b/assets/admin/pages/TreatmentCasesPage.test.tsx @@ -24,6 +24,8 @@ function caseRow(over: Record = {}) { supervisor: { uuid: 'doc-1', name: 'پزشک مدیسا' }, patient: { record_uuid: 'rec-1', name: 'محمد رسولی', mobile: '09120001111', record_number: '۱۲' }, areas: [{ uuid: 'ca-1', name: 'دست', category_uuid: 'cat-1' }], + assigned_staff: [], + performed_by: [], ...over, }; } @@ -124,6 +126,24 @@ describe('صفحهٔ پرونده‌های درمان', () => { expect(start.textContent).toMatch(/:/); }); + /** «چه کسی انجامش داد» سؤالِ اولِ مدیر است وقتی پرونده را در فهرست می‌بیند. */ + it('نام پرسنل انجام‌دهنده را روی کارت می‌آورد', async () => { + mockList([caseRow({ performed_by: [{ uuid: 'st-1', name: 'پرسنل۱' }] })]); + + renderWithProviders(); + + expect(await screen.findByText(/انجام‌دهنده: پرسنل۱/)).toBeInTheDocument(); + }); + + /** تا وقتی جلسه‌ای انجام نشده، برنامه را نشان می‌دهیم نه هیچ. */ + it('اگر هنوز انجام نشده، اپراتورِ اختصاص‌یافته را نشان می‌دهد', async () => { + mockList([caseRow({ assigned_staff: [{ uuid: 'st-2', name: 'پرسنل۲' }] })]); + + renderWithProviders(); + + expect(await screen.findByText(/اپراتور: پرسنل۲/)).toBeInTheDocument(); + }); + it('دکمهٔ ویرایش مودال را باز می‌کند', async () => { mockList([caseRow()]); diff --git a/assets/admin/pages/TreatmentCasesPage.tsx b/assets/admin/pages/TreatmentCasesPage.tsx index db0645a2..23d2729c 100644 --- a/assets/admin/pages/TreatmentCasesPage.tsx +++ b/assets/admin/pages/TreatmentCasesPage.tsx @@ -124,7 +124,7 @@ function CasesTab({ status, onStatus, search, onSearch, from, onFrom, to, onTo } setTerm(e.target.value)} - placeholder="نام بیمار، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" + placeholder="نام بیمار، پرسنل، موبایل، کد ملی، شمارهٔ پرونده یا سرویس" aria-label="جستجوی پرونده" /> {term !== '' && ( @@ -242,6 +242,15 @@ function CaseCard({ item: c, onEdit }: { item: TreatmentCaseSummary; onEdit: () {/* ساعت هم لازم است: چند پروندهٔ یک روز فقط با ساعت از هم جدا می‌شوند. */} شروع: {formatDateTime(c.opened_at)} {c.supervisor && پزشک ناظر: {c.supervisor.name}} + {/* انجام‌دهنده از جلسات می‌آید (سابقه) و اختصاص‌یافته برنامه است؛ تا وقتی + جلسه‌ای انجام نشده، همان برنامه را نشان می‌دهیم. */} + {c.performed_by.length > 0 ? ( + انجام‌دهنده: {c.performed_by.map((s) => s.name).join('، ')} + ) : c.assigned_staff.length > 0 ? ( + + اپراتور: {c.assigned_staff.map((s) => s.name).join('، ')} (هنوز انجام نشده) + + ) : null} {c.areas.length > 0 && نواحی: {c.areas.map((a) => a.name).join('، ')}}
diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index a73b9c0c..74cc2a83 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1341,6 +1341,10 @@ export interface TreatmentCaseSummary { record_number: string | null; }; areas: Array<{ uuid: string; name: string; category_uuid: string | null }>; + /** اپراتورهای اختصاص‌یافته — برنامه. خالی یعنی «هر کسی که پروتکل مجاز دانسته». */ + assigned_staff: Array<{ uuid: string; name: string }>; + /** اپراتورهایی که واقعاً جلسه‌ای از این پرونده را انجام داده‌اند — سابقه. */ + performed_by: Array<{ uuid: string; name: string }>; sessions?: TreatmentSessionSummary[]; } diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 75e6f5ad..ad17ad02 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -209,7 +209,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | Query | توضیح | |---|---| | `status` | `active` \| `completed` \| `abandoned` — نبودش یعنی همه | -| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس | +| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده، نام سرویس و **نام پرسنل** | | `from` | `YYYY-MM-DD` میلادی — پرونده‌هایی که از ابتدای این روز به بعد باز شده‌اند | | `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز | @@ -232,10 +232,19 @@ single-session again. Idempotent: deleting a service that has no protocol still "mobile": "09120001111", "record_number": "۱۲" }, - "areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ] + "areas": [ { "uuid": "…", "name": "بیکینی", "category_uuid": "…" } ], + "assigned_staff": [ { "uuid": "…", "name": "پرسنل۱" } ], + "performed_by": [ { "uuid": "…", "name": "پرسنل۱" } ] } ``` +`assigned_staff` برنامه است و `performed_by` سابقه — اولی از خودِ پرونده می‌آید و دومی +از `TreatmentSession.performedBy`. جستجوی `q` هر دو را می‌گیرد، چون «کارهای این نفر» +شامل کارِ انجام‌شده هم هست. + +**پروندهٔ بدون اختصاص یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس».** پرونده‌ای که +اپراتور دارد، جلساتش فقط در صفِ همان افراد دیده می‌شود. + `areas[].uuid` شناسهٔ همان ردیفِ ناحیه است و `category_uuid` شناسهٔ دستهٔ کاتالوگ. ویرایش با دومی کار می‌کند؛ `null` یعنی دسته حذف شده و ناحیه فقط در سابقه مانده. @@ -253,6 +262,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | `status` | `active` \| `completed` \| `abandoned`. برگرداندن به `active` پروندهٔ بسته را باز می‌کند و `closed_at` را پاک می‌کند | | `supervisor_doctor_uuid` | پزشک ناظر؛ `null` یعنی بدون ناظر | | `area_uuids` | فهرست **دستهٔ کاتالوگ**، جایگزین کامل. حداقل یکی | +| `staff_uuids` | اپراتورهای این پرونده، جایگزین کامل. فهرست خالی مجاز است | | `total_sessions` | بین `TreatmentProtocol::MIN_STEPS` و `MAX_STEPS`. کم‌کردن جلسات را از انتها حذف می‌کند | مرزِ ثابت: **هیچ ویرایشی سابقهٔ انجام‌شده را بازنویسی نمی‌کند.** @@ -263,6 +273,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | ERR_VALIDATION_001 | 422 | `area_uuids` | فهرست خالی یا نامعتبر | | ERR_VALIDATION_001 | 422 | `total_sessions` | خارج از بازهٔ مجاز | | ERR_NOT_FOUND_001 | 404 | `supervisor_doctor_uuid` / `area_uuids` | پزشک یا ناحیه یافت نشد | +| ERR_NOT_FOUND_001 | 404 | `staff_uuids` | پرسنل یافت نشد، غیرفعال است، یا مال محیط دیگری است | | ERR_CONFLICT_001 | 409 | `area_uuids` | ناحیه در جلسه‌ای ثبت شده و حذف نمی‌شود | | ERR_CONFLICT_001 | 409 | `total_sessions` | کمتر از جلساتی که نوبت دارند یا انجام شده‌اند | diff --git a/migrations/Version20260807111228.php b/migrations/Version20260807111228.php new file mode 100644 index 00000000..48ba7252 --- /dev/null +++ b/migrations/Version20260807111228.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE treatment_case_staff (id INT AUTO_INCREMENT NOT NULL, treatment_case_id INT NOT NULL, staff_id INT NOT NULL, INDEX IDX_3F9B46BB7D61639 (treatment_case_id), INDEX IDX_3F9B46BD4D57CD (staff_id), UNIQUE INDEX uq_case_staff (treatment_case_id, staff_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE treatment_case_staff ADD CONSTRAINT FK_3F9B46BB7D61639 FOREIGN KEY (treatment_case_id) REFERENCES treatment_cases (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE treatment_case_staff ADD CONSTRAINT FK_3F9B46BD4D57CD FOREIGN KEY (staff_id) REFERENCES clinic_staff (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE treatment_case_staff DROP FOREIGN KEY FK_3F9B46BB7D61639'); + $this->addSql('ALTER TABLE treatment_case_staff DROP FOREIGN KEY FK_3F9B46BD4D57CD'); + $this->addSql('DROP TABLE treatment_case_staff'); + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index ea4841b4..5a887b9f 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -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, diff --git a/src/Treatment/Entity/TreatmentCase.php b/src/Treatment/Entity/TreatmentCase.php index c748c6fa..f7efe063 100644 --- a/src/Treatment/Entity/TreatmentCase.php +++ b/src/Treatment/Entity/TreatmentCase.php @@ -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 $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 + */ + 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(); } } diff --git a/src/Treatment/Entity/TreatmentCaseStaff.php b/src/Treatment/Entity/TreatmentCaseStaff.php new file mode 100644 index 00000000..bcdf80a2 --- /dev/null +++ b/src/Treatment/Entity/TreatmentCaseStaff.php @@ -0,0 +1,53 @@ +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(), + ]; + } +} diff --git a/src/Treatment/Repository/TreatmentCaseRepository.php b/src/Treatment/Repository/TreatmentCaseRepository.php index 224be829..60c30318 100644 --- a/src/Treatment/Repository/TreatmentCaseRepository.php +++ b/src/Treatment/Repository/TreatmentCaseRepository.php @@ -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 . '%'); } diff --git a/src/Treatment/Repository/TreatmentSessionRepository.php b/src/Treatment/Repository/TreatmentSessionRepository.php index 382306ab..72a1f75e 100644 --- a/src/Treatment/Repository/TreatmentSessionRepository.php +++ b/src/Treatment/Repository/TreatmentSessionRepository.php @@ -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, )) diff --git a/src/Treatment/Service/TreatmentCaseEditor.php b/src/Treatment/Service/TreatmentCaseEditor.php index 415e9ad3..85a6bd28 100644 --- a/src/Treatment/Service/TreatmentCaseEditor.php +++ b/src/Treatment/Service/TreatmentCaseEditor.php @@ -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( diff --git a/tests/Treatment/TreatmentCaseEditTest.php b/tests/Treatment/TreatmentCaseEditTest.php index ba9a0037..be01e893 100644 --- a/tests/Treatment/TreatmentCaseEditTest.php +++ b/tests/Treatment/TreatmentCaseEditTest.php @@ -11,6 +11,7 @@ use App\Doctor\Entity\Doctor; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase; use App\Treatment\Entity\TreatmentCase; +use App\Staff\Entity\ClinicStaff; use App\Treatment\Entity\TreatmentCaseArea; use App\Treatment\Entity\TreatmentProtocol; use App\Treatment\Entity\TreatmentProtocolStep; @@ -219,6 +220,85 @@ class TreatmentCaseEditTest extends ApiTestCase self::assertSame($case->getId(), $this->cases()->findForTenant($type, $id, null, $name)[0]->getId()); } + private function staffIn(Clinic $clinic, string $name): ClinicStaff + { + $staff = new ClinicStaff('clinic', (int) $clinic->getId(), $name); + $this->em->persist($staff); + $this->em->flush(); + + return $staff; + } + + /** اپراتورِ پرونده: بیمار دوره‌اش را با یک نفر شروع می‌کند. */ + public function testAssignedStaffCanBeSetAndCleared(): void + { + [$case, $clinic] = $this->scenario(); + $a = $this->staffIn($clinic, 'اپراتور الف'); + $b = $this->staffIn($clinic, 'اپراتور ب'); + + $this->editor()->update($case, ['staff_uuids' => [$a->getUuid(), $b->getUuid()]]); + + $names = array_map(static fn ($r) => $r->getStaff()->getFullName(), $case->getAssignedStaff()->toArray()); + sort($names); + self::assertSame(['اپراتور الف', 'اپراتور ب'], $names); + + // فهرست خالی مجاز است — یعنی «هر کسی که پروتکل مجاز دانسته»، نه «هیچ‌کس». + $this->editor()->update($case, ['staff_uuids' => []]); + self::assertCount(0, $case->getAssignedStaff()); + } + + public function testStaffFromAnotherTenantIsRejected(): void + { + [$case] = $this->scenario(); + + $otherClinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $otherClinic->setName('کلینیک دیگر ' . uniqid()); + $this->em->persist($otherClinic); + $this->em->flush(); + $outsider = $this->staffIn($otherClinic, 'پرسنل بیرونی'); + + $this->expectException(\App\Shared\Exception\AppException::class); + $this->editor()->update($case, ['staff_uuids' => [$outsider->getUuid()]]); + } + + /** مدیر نام اپراتور را می‌زند و انتظار دارد پرونده‌هایش بیاید. */ + public function testSearchMatchesAssignedStaff(): void + { + [$case, $clinic] = $this->scenario(); + $name = 'اپراتور ' . uniqid(); + $staff = $this->staffIn($clinic, $name); + + $type = 'clinic'; + $id = (int) $clinic->getId(); + + self::assertSame([], $this->cases()->findForTenant($type, $id, null, $name)); + + $this->editor()->update($case, ['staff_uuids' => [$staff->getUuid()]]); + + $found = $this->cases()->findForTenant($type, $id, null, $name); + self::assertCount(1, $found); + self::assertSame($case->getId(), $found[0]->getId()); + } + + /** «کارهای این نفر» شاملِ کارِ انجام‌شده هم هست، نه فقط اختصاصِ آینده. */ + public function testSearchMatchesTheOperatorWhoPerformedASession(): void + { + [$case, $clinic] = $this->scenario(); + $name = 'انجام‌دهنده ' . uniqid(); + $staff = $this->staffIn($clinic, $name); + + $case->getSessions()->first()->setPerformedBy($staff); + $this->em->flush(); + + $found = $this->cases()->findForTenant('clinic', (int) $clinic->getId(), null, $name); + self::assertCount(1, $found); + + // و در payload هم دیده می‌شود، جدا از اختصاص. + $payload = $found[0]->toArray(); + self::assertSame([$name], array_column($payload['performed_by'], 'name')); + self::assertSame([], $payload['assigned_staff']); + } + /** فیلتر بازه روی تاریخِ باز شدن پرونده است، نه سررسید جلسه. */ public function testDateRangeFiltersByOpenedAt(): void {