Files
clinicpro/.claude/prompt/patient-treatment-plan-tab.md
hamedandClaude Opus 5 250e0b0813 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>
2026-08-07 16:05:35 +03:30

20 KiB

تب «نوبت‌های بعدی» در پروندهٔ بیمار + اصلاح واژهٔ پرسنل

پروژه

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 فقط محیط، وضعیت، جستجو و بازهٔ تاریخ دارد:

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():

if ($withSessions) {
    $data['sessions'] = array_map(
        static fn (TreatmentSession $s): array => $s->toArray(),
        $this->sessions->toArray(),
    );
}

TreatmentSession::toArray(bool $withAreas = false) نواحی را فقط با آرگومان می‌دهد و اینجا فرستاده نمی‌شود. پس «چه کاری روی چه ناحیه‌ای انجام شد» در پاسخ نیست.

تب‌های پروندهٔ بیمار

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} /> },
  // ...
];

فیلد پرسنل در مودال ثبت نوبت — بدون پیش‌فرض

// اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد،
// پر کردنش آن را از قبل به یک نفر می‌دهد.
const [staffUuid, setStaffUuid] = useState('');
<label htmlFor="appt-operator">اپراتور <span className="opt">(اختیاری)</span></label>

پروتکل، پرسنل مجاز را از قبل می‌فرستد

TreatmentProtocol::toArray():

'staff' => array_map(
    static fn (TreatmentProtocolStaff $m): array => $m->toArray(),
    $this->allowedStaff->toArray(),
),

یعنی دادهٔ لازم برای پیش‌فرض هست؛ فقط مصرف نمی‌شود.


وظایف

۱. فیلتر پرونده بر اساس بیمار

به findForTenant پارامتر ?string $recordUuid = null اضافه کن و در کنترلر از ?record= بخوان.

if ($recordUuid !== null && $recordUuid !== '') {
    $qb->join('c.patientRecord', 'prf')
        ->andWhere('prf.uuid = :record')
        ->setParameter('record', $recordUuid);
}

اگر q هم فرستاده شده باشد، patientRecord قبلاً join شده — از alias تکراری پرهیز کن (یک join مشترک، نه دو تا).

نحوه تست:

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
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} باشد، نه اضافه کردن به آن. دلیل: پاسخ فعلی مصرف‌کنندهٔ دیگری دارد (مودال ویرایش) که نواحی را لازم ندارد و بزرگ‌ترش کردن یعنی هزینهٔ بی‌مصرف روی همان مسیر.

نحوه تست:

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:

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 دارد و کاربر هنوز دستی چیزی انتخاب نکرده، اولین پرسنل را بگذار.

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:

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 تغییر نمی‌کند.