From 250e0b0813f65aef6d6dfdfaed9ff20f2323760a Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 16:05:35 +0330 Subject: [PATCH] feat(treatment): filter treatment cases by patient record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list could be narrowed by status, search and open-date, but not by patient — so a patient's own file had no way to ask which courses belong to them. `?record=` adds that bound. patientRecord is joined once and shared with the search branch; joining it twice under the same alias is a DQL error, and search already needed it. Co-Authored-By: Claude Opus 5 --- .claude/prompt/patient-treatment-plan-tab.md | 370 ++++++++++++++++++ docs/api/treatment.md | 1 + .../Controller/TreatmentCaseController.php | 2 + .../Repository/TreatmentCaseRepository.php | 25 +- tests/Treatment/TreatmentCaseEditTest.php | 26 ++ 5 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 .claude/prompt/patient-treatment-plan-tab.md diff --git a/.claude/prompt/patient-treatment-plan-tab.md b/.claude/prompt/patient-treatment-plan-tab.md new file mode 100644 index 00000000..428efafd --- /dev/null +++ b/.claude/prompt/patient-treatment-plan-tab.md @@ -0,0 +1,370 @@ +# تب «نوبت‌های بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل + +## پروژه + +`clinicpro` (backend + پنل ادمین). cross-repo نیست. + +## زمینه + +درمان چندجلسه‌ای امروز فقط از یک صفحهٔ سراسری دیده می‌شود: +`/admin/treatment-cases`. آنجا فهرست همهٔ پرونده‌های محیط است، نه پروندهٔ یک بیمار. + +نتیجه: منشی که پروندهٔ یک بیمار را باز کرده (`/admin/patients/{uuid}`) هیچ راهی ندارد +ببیند این بیمار چه دوره‌هایی دارد، جلسهٔ بعدش کِی است، و جلسات قبلی چه چیزی ثبت کرده‌اند. + +`TreatmentScheduler` هم فقط سررسید **جلسهٔ بعدی** را می‌نویسد و بقیه `null` می‌مانند +(تصمیم عمدی؛ کامنت خودِ کلاس). پس هیچ‌جا نمی‌شود کل تقویم یک دوره را دید. + +## مشکل / هدف + +سه چیز: + +1. **تب «نوبت‌های بعدی» در پروندهٔ بیمار.** دوره‌های همان بیمار، و برای هر دوره کارتِ + همهٔ جلسات با تاریخ و ساعتِ محاسبه‌شده. هر کارت دکمهٔ «ثبت نوبت» دارد که همان مودال + `NewAppointmentModal` را با همان تاریخ/ساعت باز می‌کند و کاربر می‌تواند تاریخ و ساعت + دیگری هم بگذارد. +2. **دیدن جزئیات انجام‌شده.** برای جلسات تمام‌شده باید معلوم باشد چه کسی، روی چه + ناحیه‌ای، با چه دستگاهی و با چه خوانده‌هایی (انرژی/پالس/شات) کار کرده. +3. **واژهٔ «اپراتور» → «پرسنل»** در رشته‌های کاربرپسند، و پیش‌فرض شدن پرسنلِ پروتکل در + مودال ثبت نوبت. + +--- + +## ⚠ تصمیم معماری که باید قبل از کد روشن باشد + +خواستهٔ «همهٔ نوبت‌ها را بر اساس طول درمان محاسبه کن» با یک تصمیم ثبت‌شدهٔ پروژه در +تضاد ظاهری است. متن خودِ `TreatmentScheduler`: + +> فقط جلسهٔ **بعدی** بازمحاسبه می‌شود. جلسات دورتر حدسِ قبلی‌شان را نگه می‌دارند تا +> نوبتشان برسد — عددی که هنوز به هیچ واقعیتی گره نخورده، بازمحاسبه‌اش دقیق‌ترش نمی‌کند. + +**راه‌حل انتخابی: محاسبه بشود ولی ذخیره نشود.** + +- یک سرویس **فقط‌خواندنی** تقویم کل دوره را از روی آخرین لنگرِ واقعی می‌سازد. +- `TreatmentSession.due_at` دست‌نخورده می‌ماند و همچنان فقط جلسهٔ بعدی را نگه می‌دارد. +- در UI، جلسه‌ای که `due_at` واقعی دارد با جلسه‌ای که فقط تخمین است **متفاوت** نشان + داده می‌شود. + +**چرا این و نه ذخیره کردن همه:** اگر همهٔ سررسیدها ذخیره شوند، هر بار که بیمار دیر +می‌آید باید کل زنجیره بازنویسی شود و هر نسخهٔ ذخیره‌شده یک ادعای غلط دربارهٔ آینده است. +تخمینِ محاسبه‌شده در لحظهٔ نمایش، همیشه با آخرین واقعیت هم‌خوان است و چیزی برای +نگه‌داشتن ندارد. + +**ریسک این انتخاب:** تاریخ‌های کارت با هر بار باز کردن صفحه ممکن است عوض شوند (اگر +جلسه‌ای بین دو بازدید تمام شده باشد). این پذیرفته است و باید در UI با برچسب «تخمینی» +اعلام شود. + +**گزینهٔ جایگزینی که رد شد:** ساختن نوبت‌های `pending` برای کل دوره. رد شد چون جای +دستگاه را می‌گیرد و بیمارِ نیامده وقت منبع را می‌سوزاند — همان دلیلی که «رزرو خودکار» +قبلاً رد شده بود. + +--- + +## معیار پذیرش + +- ✅ موفق: `GET /api/v1/treatment-cases?record=` با توکن منشی → ۲۰۰ و فقط + پرونده‌های همان بیمار. `GET /api/v1/treatment-case/{uuid}/plan` → ۲۰۰ با آرایهٔ + جلسات که هر کدام `planned_at` و `is_estimate` دارند. +- ✅ موفق: در `/admin/patients/{uuid}?tab=treatment` کارت هر جلسه تاریخ و ساعت شمسی + نشان می‌دهد و دکمهٔ «ثبت نوبت» مودال `NewAppointmentModal` را با همان تاریخ/ساعت + و همان بیمار و سرویس باز می‌کند. +- ✅ موفق: در مودال ثبت نوبت، وقتی سرویسی با پروتکل فعال انتخاب می‌شود، فیلد «پرسنل» + از `TreatmentProtocol.staff` پیش‌فرض پر می‌شود. +- ❌ خطا: `GET /api/v1/treatment-case/{uuid}/plan` برای پروندهٔ محیط دیگر → ۴۰۴ با + envelope خطا. `?record=` با uuid ناموجود → آرایهٔ خالی، نه ۵۰۰. +- ⚠️ مرزی: بیمارِ بدون هیچ دوره → تب «نوبت‌های بعدی» حالت خالیِ طراحی‌شده دارد، نه + اسکلتون بی‌پایان. +- ⚠️ مرزی: دوره‌ای که همهٔ جلساتش تمام شده → کارت‌ها همه «انجام‌شده» با جزئیات، و هیچ + دکمهٔ «ثبت نوبت» فعالی ندارند. +- ⚠️ مرزی: جلسه‌ای که نوبت دارد → دکمه‌اش «ثبت نوبت» نیست؛ نوبت موجود نشان داده می‌شود. +- ⚠️ مرزی: پروتکلی که هیچ پرسنلی ندارد → فیلد «پرسنل» خالی می‌ماند و فرم قفل نمی‌شود. + +--- + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Treatment/Controller/TreatmentCaseController.php` | فهرست و جزئیات پرونده؛ اندپوینت‌های جدید اینجا | +| `src/Treatment/Repository/TreatmentCaseRepository.php` | `findForTenant` — فیلتر `record` اینجا اضافه می‌شود | +| `src/Treatment/Service/TreatmentScheduler.php` | محاسبهٔ سررسید؛ **دست‌نخورده می‌ماند** | +| `src/Treatment/Entity/TreatmentCase.php` | `toArray(withSessions)` — امروز نواحی را نمی‌فرستد | +| `src/Treatment/Entity/TreatmentSession.php` | `toArray(withAreas)` | +| `src/Treatment/Entity/SessionAreaRecord.php` | خوانده‌های دستگاه — منبع «ورک‌فلوی انجام‌شده» | +| `assets/admin/pages/PatientDetailPage.tsx` | تب‌های پروندهٔ بیمار | +| `assets/admin/components/appointments/NewAppointmentModal.tsx` | مودال ثبت نوبت | +| `assets/admin/components/TreatmentCaseEditModal.tsx` | لیبل «اپراتور» | +| `assets/admin/pages/TreatmentCasesPage.tsx` | رشتهٔ «اپراتور:» | +| `assets/admin/pages/StaffSessionDetailPage.tsx` | رشتهٔ «اپراتور:» | +| `docs/api/treatment.md` | سند اندپوینت‌ها | + +--- + +## وضعیت فعلی + +### فیلتر پرونده بر اساس بیمار وجود ندارد + +`TreatmentCaseRepository::findForTenant` فقط محیط، وضعیت، جستجو و بازهٔ تاریخ دارد: + +```php +public function findForTenant( + string $entityType, + int $entityId, + ?string $status = null, + ?string $q = null, + ?int $openedFrom = null, + ?int $openedTo = null, +): array { + $qb = $this->createQueryBuilder('c') + ->where('c.entityType = :type') + ->andWhere('c.entityId = :id') + // ... +``` + +### جزئیات پرونده نواحی جلسات را نمی‌فرستد + +`TreatmentCase::toArray()`: + +```php +if ($withSessions) { + $data['sessions'] = array_map( + static fn (TreatmentSession $s): array => $s->toArray(), + $this->sessions->toArray(), + ); +} +``` + +`TreatmentSession::toArray(bool $withAreas = false)` نواحی را فقط با آرگومان می‌دهد و +اینجا فرستاده نمی‌شود. پس «چه کاری روی چه ناحیه‌ای انجام شد» در پاسخ نیست. + +### تب‌های پروندهٔ بیمار + +```tsx +type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records'; + +const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [ + { key: 'services', label: 'سرویس‌ها', icon: (c) => }, + // ... +]; +``` + +### فیلد پرسنل در مودال ثبت نوبت — بدون پیش‌فرض + +```tsx +// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد، +// پر کردنش آن را از قبل به یک نفر می‌دهد. +const [staffUuid, setStaffUuid] = useState(''); +``` + +```tsx + +``` + +### پروتکل، پرسنل مجاز را از قبل می‌فرستد + +`TreatmentProtocol::toArray()`: + +```php +'staff' => array_map( + static fn (TreatmentProtocolStaff $m): array => $m->toArray(), + $this->allowedStaff->toArray(), +), +``` + +یعنی دادهٔ لازم برای پیش‌فرض هست؛ فقط مصرف نمی‌شود. + +--- + +## وظایف + +### ۱. فیلتر پرونده بر اساس بیمار + +به `findForTenant` پارامتر `?string $recordUuid = null` اضافه کن و در کنترلر از +`?record=` بخوان. + +```php +if ($recordUuid !== null && $recordUuid !== '') { + $qb->join('c.patientRecord', 'prf') + ->andWhere('prf.uuid = :record') + ->setParameter('record', $recordUuid); +} +``` + +> اگر `q` هم فرستاده شده باشد، `patientRecord` قبلاً join شده — از alias تکراری +> پرهیز کن (یک join مشترک، نه دو تا). + +**نحوه تست:** +```bash +TOKEN=... # 09390039833 / QaTest@1234 +curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-cases?record=" \ + -H "Authorization: Bearer $TOKEN" +``` +باید فقط پرونده‌های همان بیمار برگردد. با `record=` نامعتبر → آرایهٔ خالی. +تست PHPUnit در `tests/Treatment/TreatmentCaseEditTest.php` کنار تست‌های `findForTenant`. + +--- + +### ۲. سرویس تقویم دوره (فقط‌خواندنی) + +`src/Treatment/Service/TreatmentPlanProjector.php` + +مسئولیت واحد: از روی جلسات یک پرونده، تاریخ **همهٔ** جلسات را بساز. + +قاعده: +- جلسهٔ انجام‌شده → `planned_at = finished_at`، `is_estimate = false` +- جلسه‌ای که نوبت دارد → `planned_at = appointment.slot_start`، `is_estimate = false` +- جلسه‌ای که `due_at` دارد → `planned_at = due_at`، `is_estimate = false` +- بقیه → از آخرین لنگر به بعد، با جمع زدن `offset_days` گام‌ها، `is_estimate = true` + +```php +final class TreatmentPlanProjector +{ + private const DAY = 86400; + + /** @return list */ + public function project(TreatmentCase $case): array + { + // جلسات به ترتیب شماره؛ لنگر = آخرین زمان قطعیِ دیده‌شده + // برای هر جلسهٔ بی‌لنگر: anchor += offsetDays(stepNumber) * DAY + } +} +``` + +**چرا سرویس جدا و نه متد روی `TreatmentScheduler`:** آن کلاس *می‌نویسد* و این فقط +*می‌خواند*. قاطی کردنشان یعنی یک کلاس با دو مسئولیت و ریسک اینکه تخمین اشتباهی +ذخیره شود (guidelines §۵، اصل S). + +**نحوه تست:** تست واحد در `tests/Treatment/` — پرونده‌ای با ۴ جلسه بساز، جلسهٔ ۱ را +تمام کن، و بررسی کن جلسهٔ ۲ `is_estimate=false` (چون `due_at` گرفته) و جلسات ۳ و ۴ +`is_estimate=true` با فاصلهٔ درستِ گام‌هایشان باشند. حالت مرزی: پرونده‌ای که هیچ جلسهٔ +تمام‌شده‌ای ندارد و نوبت هم ندارد → همه `is_estimate=true` با لنگرِ `opened_at`. + +--- + +### ۳. اندپوینت تقویم + جزئیات انجام‌شده + +`GET /api/v1/treatment-case/{uuid}/plan` + +پاسخ برای هر جلسه: `uuid`، `session_number`، `status`، `planned_at`، `is_estimate`، +`appointment` (اگر دارد)، `performed_by`، و **`areas`** با `parameters`، `resource`، +`note`، `started_at`، `finished_at`. + +نواحی همان چیزی است که کاربر «ورک‌فلوی انجام‌شده» می‌نامد. `SessionAreaRecord::toArray()` +از قبل همه را دارد؛ فقط باید `withAreas: true` فرستاده شود. + +**تصمیم:** اندپوینت جدا از `GET /treatment-case/{uuid}` باشد، نه اضافه کردن به آن. +دلیل: پاسخ فعلی مصرف‌کنندهٔ دیگری دارد (مودال ویرایش) که نواحی را لازم ندارد و +بزرگ‌ترش کردن یعنی هزینهٔ بی‌مصرف روی همان مسیر. + +**نحوه تست:** +```bash +curl -sk "https://clinic-pro.ddev.site/api/v1/treatment-case//plan" -H "Authorization: Bearer $TOKEN" +``` +باید همهٔ جلسات با `planned_at` بیایند و جلسهٔ انجام‌شده `areas[].parameters` داشته باشد. +پروندهٔ محیط دیگر → ۴۰۴. + +--- + +### ۴. تب «نوبت‌های بعدی» در پروندهٔ بیمار + +`TabKey` را با `'treatment'` گسترش بده و به `TABS` اضافه کن. + +کامپوننت جدید `assets/admin/components/patient/PatientTreatmentTab.tsx`: + +- `GET /api/v1/treatment-cases?record={uuid}` → فهرست دوره‌ها +- برای دورهٔ باز، `GET /api/v1/treatment-case/{uuid}/plan` +- هر جلسه یک کارت: شمارهٔ جلسه، تاریخ و ساعت شمسی (`formatDateTime`)، وضعیت با + `StatusBadge type="treatment-session"`، و برچسب «تخمینی» وقتی `is_estimate` +- جلسهٔ انجام‌شده: پرسنل + نواحی + خوانده‌های دستگاه +- جلسهٔ بدون نوبت: دکمهٔ «ثبت نوبت» + +**دکمهٔ «ثبت نوبت» باید همان `NewAppointmentModal` را باز کند** — نه صفحهٔ جدید و نه +مودال تازه. همان مودالی که در `/admin/appointments?resource=…` استفاده می‌شود. + +مودال این propها را می‌گیرد: `slot`، `services`، `date`، `clinicUuid`، `resource`. +برای اینکه تاریخ و ساعتِ کارت پیش‌فرض شود، `date` را از `planned_at` بده. + +> **این را قبل از کد بررسی کن:** مودال امروز `treatment_session_uuid` را نمی‌فرستد +> (فقط صفحهٔ `AppointmentCreatePage` می‌فرستد). برای اینکه نوبت به همان جلسه بچسبد، +> باید prop تازه‌ای مثل `treatmentSessionUuid` به مودال اضافه شود و در payload برود. +> بدون آن، اتصال دوباره به حدسِ سرویس می‌افتد — همان چیزی که در +> `SessionBookingLink` صریح شد. + +**نحوه تست UI:** +```bash +node .claude/skills/redesign-page/driver.mjs shot \ + "https://clinic-pro.ddev.site/admin/patients/?tab=treatment" \ + --out /tmp/plan.png --full --wait 5000 +``` +سناریو: بیماری با دورهٔ باز → کارت‌ها دیده شوند؛ کلیک روی «ثبت نوبت» → مودال با تاریخ +همان کارت باز شود؛ ثبت → جلسه `booked` شود و کارت به‌روز شود. + +--- + +### ۵. پیش‌فرض شدن پرسنلِ پروتکل در مودال ثبت نوبت + +در `NewAppointmentModal`، وقتی `pick.serviceUuids` تغییر می‌کند، پروتکل سرویس اول را +بخوان و اگر `staff` دارد و کاربر هنوز دستی چیزی انتخاب نکرده، اولین پرسنل را بگذار. + +```tsx +const protocolQ = useQuery({ + queryKey: ['service-protocol', pick.serviceUuids[0]], + queryFn: () => api.get(`/api/v1/service-item/${pick.serviceUuids[0]}/treatment-protocol`), + enabled: pick.serviceUuids.length > 0, +}); +``` + +**قاعده:** فقط وقتی پیش‌فرض بگذار که کاربر دست نزده باشد (یک فلگ `staffTouched`). +وگرنه انتخاب دستیِ منشی با هر تغییر سرویس پاک می‌شود. + +**نحوه تست:** vitest در `assets/admin/components/appointments/ResourceBookingModal.test.tsx` — +mock کردن پاسخ پروتکل با یک پرسنل، انتخاب سرویس، و بررسی اینکه `staff_uuid` در payload +همان است. حالت مرزی: پروتکل بدون پرسنل → فیلد خالی و ثبت همچنان ممکن. + +--- + +### ۶. «اپراتور» → «پرسنل» + +فقط رشته‌های کاربرپسند. چهار مورد: + +| فایل | خط | رشته | +|------|----|------| +| `assets/admin/components/appointments/NewAppointmentModal.tsx` | ۲۸۴ | `اپراتور (اختیاری)` | +| `assets/admin/components/TreatmentCaseEditModal.tsx` | ۲۱۲ | `اپراتور` | +| `assets/admin/pages/TreatmentCasesPage.tsx` | ۳۵۷ | `اپراتور: …` | +| `assets/admin/pages/StaffSessionDetailPage.tsx` | ۱۱۶ | `اپراتور: …` | + +**دست نزن به** `assets/admin/pages/ResourcesPage.tsx:107`: + +```tsx +description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان." +``` + +آنجا «اپراتور» یک **نوع منبع** است، نه رکورد `ClinicStaff`. عوض کردنش معنی جمله را +خراب می‌کند. اگر مطمئن نیستی، از کاربر بپرس. + +کامنت‌های کد را هم می‌توانی هماهنگ کنی ولی اولویت ندارد. + +**نحوه تست:** `npx vitest run` — تستی که به متن «اپراتور» تکیه کرده باشد باید به‌روز +شود؛ بعد grep بزن که هیچ رشتهٔ کاربرپسندِ «اپراتور» جز مورد `ResourcesPage` نمانده باشد. + +--- + +## نکات مهم + +- **`TreatmentScheduler` تغییر نمی‌کند.** تقویم تخمینی فقط خوانده می‌شود و `due_at` + هیچ‌وقت از این مسیر نوشته نمی‌شود. اگر خودت را در حال `setDueAt` دیدی، از مسیر + خارج شده‌ای. +- الگوی به‌کاررفته: **Projection/Read Model** — یک سرویس فقط‌خواندنی که از داده‌های + موجود یک نمای مشتق می‌سازد. دلیل انتخاب: نما با هر تغییرِ واقعیت خودش به‌روز است و + هیچ داده‌ای برای همگام‌سازی ندارد. +- سه حالت داده (Loading / Empty / Error) برای تب جدید اجباری است — الگوی موجود در + `TreatmentCasesPage.tsx` را ببین: خطای سرور نباید «دوره‌ای ندارد» خوانده شود. +- ارقام با `formatNumber` و تاریخ‌ها با `formatDateTime` (شمسی). ارقام لاتین وسط متن + فارسی، نقصِ تکرارشوندهٔ این صفحه‌ها بوده. +- وضعیت تب در URL است (`?tab=treatment`) — `PatientDetailPage` از قبل `searchParams` + را می‌خواند. +- بعد از هر تغییر API، `docs/api/treatment.md` در همان جلسه به‌روز شود. +- تنانسی: هر دو اندپوینت جدید باید از `requireCase`/`branches->pair($user)` عبور کنند؛ + پروندهٔ محیط دیگر ۴۰۴ می‌گیرد نه ۴۰۳. +- migration لازم **نیست** — هیچ Entity تغییر نمی‌کند. diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 7c90b87d..d6fbe087 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -212,6 +212,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده، نام سرویس و **نام پرسنل** | | `from` | `YYYY-MM-DD` میلادی — پرونده‌هایی که از ابتدای این روز به بعد باز شده‌اند | | `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز | +| `record` | uuid پروندهٔ بیمار — فقط دوره‌های همان بیمار. نمای «پروندهٔ بیمار» همین فهرست است. | بازه روی `opened_at` است نه سررسید جلسه. تایم‌زون تهران (`config/bootstrap_tz.php`). مقدارِ بدفرم بی‌صدا نادیده گرفته می‌شود، نه خطا — فیلتر است نه ورودی فرم. diff --git a/src/Treatment/Controller/TreatmentCaseController.php b/src/Treatment/Controller/TreatmentCaseController.php index 68f6811c..095102b2 100644 --- a/src/Treatment/Controller/TreatmentCaseController.php +++ b/src/Treatment/Controller/TreatmentCaseController.php @@ -59,6 +59,8 @@ class TreatmentCaseController extends BaseController $q !== '' ? $q : null, $this->dayBoundary($request->query->get('from'), '00:00:00'), $this->dayBoundary($request->query->get('to'), '23:59:59'), + // نمای «پروندهٔ بیمار» همین فهرست است با یک کران بیشتر. + is_string($record = $request->query->get('record')) && $record !== '' ? $record : null, ), )); } diff --git a/src/Treatment/Repository/TreatmentCaseRepository.php b/src/Treatment/Repository/TreatmentCaseRepository.php index 60c30318..87660759 100644 --- a/src/Treatment/Repository/TreatmentCaseRepository.php +++ b/src/Treatment/Repository/TreatmentCaseRepository.php @@ -40,9 +40,10 @@ class TreatmentCaseRepository extends ServiceEntityRepository /** @return TreatmentCase[] */ /** - * @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس. - * منشی همان کلیدی را می‌زند که در فرم نوبت می‌زند، پس هر چهار - * شناسهٔ بیمار باید بگیرد نه فقط نام. + * @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس. + * منشی همان کلیدی را می‌زند که در فرم نوبت می‌زند، پس هر چهار + * شناسهٔ بیمار باید بگیرد نه فقط نام. + * @param ?string $recordUuid فقط دوره‌های همین بیمار — نمای «پروندهٔ بیمار». */ public function findForTenant( string $entityType, @@ -51,6 +52,7 @@ class TreatmentCaseRepository extends ServiceEntityRepository ?string $q = null, ?int $openedFrom = null, ?int $openedTo = null, + ?string $recordUuid = null, ): array { $qb = $this->createQueryBuilder('c') ->where('c.entityType = :type') @@ -59,6 +61,18 @@ class TreatmentCaseRepository extends ServiceEntityRepository ->setParameter('id', $entityId) ->orderBy('c.openedAt', 'DESC'); + $hasSearch = $q !== null && $q !== ''; + + // `patientRecord` را یک بار join می‌کنیم؛ هم فیلتر بیمار و هم جستجو لازمش + // دارند و دو join با یک alias خطای DQL است. + if ($hasSearch || ($recordUuid !== null && $recordUuid !== '')) { + $qb->join('c.patientRecord', 'pr'); + } + + if ($recordUuid !== null && $recordUuid !== '') { + $qb->andWhere('pr.uuid = :record')->setParameter('record', $recordUuid); + } + if ($status !== null) { $qb->andWhere('c.status = :status')->setParameter('status', $status); } @@ -73,7 +87,7 @@ class TreatmentCaseRepository extends ServiceEntityRepository $qb->andWhere('c.openedAt <= :to')->setParameter('to', $openedTo); } - if ($q !== null && $q !== '') { + if ($hasSearch) { /** * پرسنل از دو راه می‌آید: کسی که به پرونده اختصاص یافته، و کسی که واقعاً * جلسه‌ای از آن را انجام داده. مدیر که نام اپراتور را می‌زند هر دو را @@ -95,8 +109,7 @@ class TreatmentCaseRepository extends ServiceEntityRepository ) DQL; - $qb->join('c.patientRecord', 'pr') - ->join('pr.user', 'u') + $qb->join('pr.user', 'u') ->join('c.serviceItem', 'si') ->andWhere( 'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q' diff --git a/tests/Treatment/TreatmentCaseEditTest.php b/tests/Treatment/TreatmentCaseEditTest.php index be01e893..5b70812f 100644 --- a/tests/Treatment/TreatmentCaseEditTest.php +++ b/tests/Treatment/TreatmentCaseEditTest.php @@ -299,6 +299,32 @@ class TreatmentCaseEditTest extends ApiTestCase self::assertSame([], $payload['assigned_staff']); } + /** نمای «پروندهٔ بیمار»: فقط دوره‌های همان بیمار. */ + public function testFilteringByPatientRecord(): void + { + [$case, $clinic] = $this->scenario('بیمار الف ' . uniqid()); + $recordUuid = $case->getPatientRecord()->getUuid(); + + // دورهٔ دومی برای بیمار دیگری در همان محیط + [$other] = $this->scenario('بیمار ب ' . uniqid()); + + $type = 'clinic'; + $id = (int) $clinic->getId(); + + $mine = $this->cases()->findForTenant($type, $id, null, null, null, null, $recordUuid); + self::assertCount(1, $mine); + self::assertSame($case->getId(), $mine[0]->getId()); + + // uuid ناموجود → آرایهٔ خالی، نه خطا + self::assertSame([], $this->cases()->findForTenant($type, $id, null, null, null, null, 'does-not-exist')); + + // فیلتر بیمار و جستجو با هم — یک join مشترک، نه دو تا + $name = $case->getPatientRecord()->getUser()->getRealName(); + self::assertCount(1, $this->cases()->findForTenant($type, $id, null, $name, null, null, $recordUuid)); + + self::assertNotSame($case->getId(), $other->getId()); + } + /** فیلتر بازه روی تاریخِ باز شدن پرونده است، نه سررسید جلسه. */ public function testDateRangeFiltersByOpenedAt(): void {