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 {