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:
@@ -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 تغییر نمیکند.
|
||||||
@@ -212,6 +212,7 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
|||||||
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده، نام سرویس و **نام پرسنل** |
|
| `q` | جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده، نام سرویس و **نام پرسنل** |
|
||||||
| `from` | `YYYY-MM-DD` میلادی — پروندههایی که از ابتدای این روز به بعد باز شدهاند |
|
| `from` | `YYYY-MM-DD` میلادی — پروندههایی که از ابتدای این روز به بعد باز شدهاند |
|
||||||
| `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز |
|
| `to` | `YYYY-MM-DD` میلادی — تا انتهای این روز |
|
||||||
|
| `record` | uuid پروندهٔ بیمار — فقط دورههای همان بیمار. نمای «پروندهٔ بیمار» همین فهرست است. |
|
||||||
|
|
||||||
بازه روی `opened_at` است نه سررسید جلسه. تایمزون تهران (`config/bootstrap_tz.php`).
|
بازه روی `opened_at` است نه سررسید جلسه. تایمزون تهران (`config/bootstrap_tz.php`).
|
||||||
مقدارِ بدفرم بیصدا نادیده گرفته میشود، نه خطا — فیلتر است نه ورودی فرم.
|
مقدارِ بدفرم بیصدا نادیده گرفته میشود، نه خطا — فیلتر است نه ورودی فرم.
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ class TreatmentCaseController extends BaseController
|
|||||||
$q !== '' ? $q : null,
|
$q !== '' ? $q : null,
|
||||||
$this->dayBoundary($request->query->get('from'), '00:00:00'),
|
$this->dayBoundary($request->query->get('from'), '00:00:00'),
|
||||||
$this->dayBoundary($request->query->get('to'), '23:59:59'),
|
$this->dayBoundary($request->query->get('to'), '23:59:59'),
|
||||||
|
// نمای «پروندهٔ بیمار» همین فهرست است با یک کران بیشتر.
|
||||||
|
is_string($record = $request->query->get('record')) && $record !== '' ? $record : null,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
/** @return TreatmentCase[] */
|
/** @return TreatmentCase[] */
|
||||||
/**
|
/**
|
||||||
* @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس.
|
* @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس.
|
||||||
* منشی همان کلیدی را میزند که در فرم نوبت میزند، پس هر چهار
|
* منشی همان کلیدی را میزند که در فرم نوبت میزند، پس هر چهار
|
||||||
* شناسهٔ بیمار باید بگیرد نه فقط نام.
|
* شناسهٔ بیمار باید بگیرد نه فقط نام.
|
||||||
|
* @param ?string $recordUuid فقط دورههای همین بیمار — نمای «پروندهٔ بیمار».
|
||||||
*/
|
*/
|
||||||
public function findForTenant(
|
public function findForTenant(
|
||||||
string $entityType,
|
string $entityType,
|
||||||
@@ -51,6 +52,7 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
?string $q = null,
|
?string $q = null,
|
||||||
?int $openedFrom = null,
|
?int $openedFrom = null,
|
||||||
?int $openedTo = null,
|
?int $openedTo = null,
|
||||||
|
?string $recordUuid = null,
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
->where('c.entityType = :type')
|
->where('c.entityType = :type')
|
||||||
@@ -59,6 +61,18 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
->setParameter('id', $entityId)
|
->setParameter('id', $entityId)
|
||||||
->orderBy('c.openedAt', 'DESC');
|
->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) {
|
if ($status !== null) {
|
||||||
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||||
}
|
}
|
||||||
@@ -73,7 +87,7 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
$qb->andWhere('c.openedAt <= :to')->setParameter('to', $openedTo);
|
$qb->andWhere('c.openedAt <= :to')->setParameter('to', $openedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($q !== null && $q !== '') {
|
if ($hasSearch) {
|
||||||
/**
|
/**
|
||||||
* پرسنل از دو راه میآید: کسی که به پرونده اختصاص یافته، و کسی که واقعاً
|
* پرسنل از دو راه میآید: کسی که به پرونده اختصاص یافته، و کسی که واقعاً
|
||||||
* جلسهای از آن را انجام داده. مدیر که نام اپراتور را میزند هر دو را
|
* جلسهای از آن را انجام داده. مدیر که نام اپراتور را میزند هر دو را
|
||||||
@@ -95,8 +109,7 @@ class TreatmentCaseRepository extends ServiceEntityRepository
|
|||||||
)
|
)
|
||||||
DQL;
|
DQL;
|
||||||
|
|
||||||
$qb->join('c.patientRecord', 'pr')
|
$qb->join('pr.user', 'u')
|
||||||
->join('pr.user', 'u')
|
|
||||||
->join('c.serviceItem', 'si')
|
->join('c.serviceItem', 'si')
|
||||||
->andWhere(
|
->andWhere(
|
||||||
'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q'
|
'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q'
|
||||||
|
|||||||
@@ -299,6 +299,32 @@ class TreatmentCaseEditTest extends ApiTestCase
|
|||||||
self::assertSame([], $payload['assigned_staff']);
|
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
|
public function testDateRangeFiltersByOpenedAt(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user