feat(treatment): filter treatment cases by patient record

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 16:05:35 +03:30
co-authored by Claude Opus 5
parent fbaf98a1f5
commit 250e0b0813
5 changed files with 418 additions and 6 deletions
@@ -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=<record_uuid>` با توکن منشی → ۲۰۰ و فقط
پرونده‌های همان بیمار. `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) => <TabServices color={c} /> },
// ...
];
```
### فیلد پرسنل در مودال ثبت نوبت — بدون پیش‌فرض
```tsx
// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد،
// پر کردنش آن را از قبل به یک نفر می‌دهد.
const [staffUuid, setStaffUuid] = useState('');
```
```tsx
<label htmlFor="appt-operator">اپراتور <span className="opt">(اختیاری)</span></label>
```
### پروتکل، پرسنل مجاز را از قبل می‌فرستد
`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=<record_uuid>" \
-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<array{session: TreatmentSession, planned_at: ?int, is_estimate: bool}> */
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/<uuid>/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/<uuid>?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 تغییر نمی‌کند.