Files
clinicpro/.claude/prompt/resource-first-appointments.md
T
hamedandClaude Opus 5 b03ae95bf8 docs(prompt): record what actually happened in the resource-first task
The prompt was wrong three times and the file now says so up front: the
doctor-less appointment path was built and fully reverted, the getDoctor()
blast radius was overstated, and the booking engine it asked to build already
existed. Also records two tooling traps found on the way — phpstan runs at
level 5 here so it never checks nullability, and migrations:diff missed the
nullable change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:44:54 +03:30

23 KiB
Raw Blame History

نوبت‌دهی منبع‌محور در صفحهٔ نوبت‌ها — تب هر منبع + رزرو سرویسی + حذف تایم‌لاین منابع

وضعیت: انجام شد (۱۴۰۵/۰۵/۱۲). کامیت‌ها: 3e3a2482 (تب منابع + فیلتر)، fd27ceef (مودال رزرو + حذف تایم‌لاین).

این پرامپت در اجرا سه‌بار غلط از آب درآمد — چیزی که واقعاً شد:

۱. «نوبت بدون پزشک» پیاده شد و بعد کاملاً برگردانده شد. doctor تهی‌پذیر شد و migration اجرا شد، ولی وسط کار معلوم شد مدل مجوز منشی روی سه‌تایی (منشی، کلینیک، پزشک) بنا شده و DoctorSecretary.doctor تهی‌پذیر نیست — یعنی برای نوبتِ بدون پزشک هیچ ردیف مجوزی وجود ندارد. تصمیم کاربر: منبع پزشکِ مسئول داشته باشد. migration رول‌بک و کد git checkout شد.

۲. «۷۳ فراخوانی getDoctor()» بیش‌برآورد بود. بیشترشان روی entityهای دیگرند (WeeklySchedule، Holiday، Rate، DoctorAddress). عدد واقعی روی Appointment حدود ۲۵ بود، و diff سطح ۸ دقیقاً ۳۳ نقطهٔ جدید در ۱۶ فایل داد.

۳. مهم‌ترین: کل موتور از قبل ساخته شده بود و این پرامپت از وجودش بی‌خبر بود. POST /api/v1/appointment-availability (با assignment per اسلات)، appointment-hold، appointment-confirm، هوک useResourceBooking.ts، و حتی یک صفحهٔ کامل ResourceBookingPage.tsx روی /admin/resource-booking. confirm هم از قبل doctor_uuid می‌گیرد. پس هیچ تغییر بک‌اندی برای رزرو لازم نبود و تنها افزودنی بک‌اند، فیلتر resource_uuid روی فهرست نوبت‌ها شد.

دو تلهٔ ابزاری که باید بدانی

  • phpstan این پروژه تهی‌پذیری را چک نمی‌کند. بررسی «صدا زدن متد روی تهی» سطح ۸ است و phpstan.neon روی سطح ۵. با یک خطای عمدی تست شد: [OK] No errors. برای این جنس تغییر، گیت واقعی PHPUnit است، نه phpstan.
  • doctrine:migrations:diff تغییر nullable را ندید و به‌جایش یک migration بی‌ربط messenger_messages ساخت. migration دستی نوشته شد.

معماری‌ای که ماند

  • رزرو منبع از hold → confirm می‌رود، نه POST /api/v1/appointment. دلیلش حیاتی است: فقط HoldService رکورد resource_occupancy_buckets می‌نویسد و قید یکتای uniq_bucket_resource_seat تداخل را غیرممکن می‌کند. book() هیچ occupancy نمی‌نویسد، پس رزرو منبع از آن مسیر بی‌گارد است.
  • موتور خدمت‌محور جواب می‌دهد؛ مودال نتیجه را به اسلات‌هایی تنگ می‌کند که assignmentشان همین منبع را دارد و آن نقش را به منبع قفل می‌کند.

بقیهٔ این فایل متن اولیهٔ پرامپت است و برای تاریخچه نگه داشته شده — به‌عنوان دستورالعمل اجرا معتبر نیست.


زمینه

صفحهٔ /admin/appointments امروز کاملاً پزشک‌محور است: تب‌ها فقط پزشک‌اند (AppointmentsPage.tsx:841)، و منابع فقط یک نوار فقط‌خواندنی زیر زمان‌بندی دارند (AppointmentsPage.tsx:892-903) که هیچ اقدامی روی آن ممکن نیست.

در مدل Resource-First، منبع واحد ظرفیت است: «لیزر CO2» و «لیزر NdYAG» سرویس‌های خودشان (ResourceServiceOffering)، تقویم خودشان (ResourceCalendar) و استثناهای خودشان را دارند. ولی کاربر نمی‌تواند برای آن‌ها نوبت ثبت کند، چون کل مسیر رزرو از پزشک عبور می‌کند.

مشکل / هدف

هدف: هر منبع مثل پزشک تب خودش را داشته باشد؛ «افزودن نوبت» روی تب یک منبع، مودال نوبت‌دهی سرویسی را برای همان منبع باز کند (لیست سرویس‌های همان منبع → انتخاب → زمان خالی → بیمار → ثبت)؛ و نوار «منابع» زیر زمان‌بندی حذف شود.

سه مانع واقعی در کد امروز:

۱. نوبت بدون پزشک ممکن نیست. Appointment::$doctor با nullable: false تعریف شده (Appointment.php:100-101) و سازندهٔ entity هم Doctor می‌گیرد (Appointment.php:256). book() بدون doctor_uuid خطای ۴۲۲ می‌دهد (AppointmentController.php:484) و منبع فقط وقتی پزشک پیدا می‌کند که خودش پزشک باشد (AppointmentController.php:471-473). یعنی دستگاهِ بدون پزشک اصلاً قابل رزرو نیست.

۲. اسلات سرویسی فقط پزشک‌محور است. GET /api/v1/appointment-service-slots پزشک می‌خواهد و شرط می‌کند booking_mode همان پزشک service باشد (AppointmentController.php:193-209). منبع WeeklySchedule ندارد.

۳. تایم‌لاین منابع باید حذف شود.

تصمیم گرفته‌شده (توسط کاربر): مسیر «نوبت بدون پزشک» — doctor تهی‌پذیر شود.

چرا این تصمیم آن‌قدر که به‌نظر می‌رسد پرریسک نیست (شواهد از کد)

  • Appointment::$resource از قبل وجود دارد و تهی‌پذیر است (Appointment.php:174-176).
  • تداخل منابع از قبل در سطح دیتابیس تضمین شده، نه در کد: OccupancyBucket با UniqueConstraint('uniq_bucket_resource_seat', ['resource_id','bucket_at','seat']) (OccupancyBucket.php:22). پس activeSlotKey مسئول تداخل منبع نیست.
  • activeSlotKey فقط دوباره‌رزروی همان پزشک را می‌گیرد: sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) (Appointment.php:283). وقتی پزشکی وجود ندارد، «دوباره‌رزروی پزشک» بی‌معناست و null بودنِ کلید معنای درستی است — نه یک حفرهٔ ایمنی.

ریسک واقعی و باقی‌مانده: ۷۳ فراخوانی getDoctor() در ۲۱+ فایل src/ که همه امروز Doctor غیرتهی فرض می‌کنند. این بخش سنگین کار است و باید تک‌تک بررسی شود.

معیار پذیرش

  • موفق: با توکن مالک کلینیک، POST /api/v1/appointment با بدنهٔ {resource_uuid, service_item_uuids[], slot_start, patient_national_code, patient_gender} و بدون doctor_uuid، برای منبعِ دستگاهی (subject_kind = null) → 200 و نوبت ذخیره می‌شود با doctor_id = NULL و resource_id پرشده. در UI: تب «لیزر CO2» → «افزودن نوبت» → انتخاب سرویس → انتخاب زمان → ثبت → نوبت در فهرست همان تب دیده می‌شود.
  • موفق: GET /api/v1/appointment-service-slots?resource_uuid=…&date=…&service_item_uuids[]=…200 با همان شکل پاسخِ حالت پزشک (start_times[], total_duration_minutes).
  • خطا: رزرو منبعی که آن سرویس را ارائه نمی‌دهد → 422 با پیام «این منبع این سرویس را ارائه نمی‌دهد» و field = resource_uuid (این بررسی از قبل در AppointmentController.php:533-540 هست و باید در مسیر بدون‌پزشک هم اجرا شود).
  • خطا: رزرو منبعِ محیط دیگر → 422 «منبع یافت نشد» با field = resource_uuid.
  • خطا: نه doctor_uuid و نه resource_uuid422 با envelope خطا.
  • ⚠️ مرزی: دو رزروِ هم‌زمان روی یک منبع با ظرفیت ۱ در یک بازه → دومی باید با 409 رد شود (از قید یکتای uniq_bucket_resource_seat، نه از بررسی در کد).
  • ⚠️ مرزی: منبعی که آن روز شیفت ندارد → start_times خالی و پیام «زمان خالی کافی نیست»، نه خطای ۵۰۰.
  • ⚠️ مرزی: نوبت‌های قدیمیِ دارای پزشک باید بدون تغییر کار کنند (هم API، هم پنل، هم سایت عمومی) — doctor تهی‌پذیر شده، حذف نشده.

فایل‌های مرتبط

فایل نقش
src/Appointment/Entity/Appointment.php doctor تهی‌پذیر، activeSlotKey، toArray()
src/Appointment/Controller/AppointmentController.php book() و serviceSlots()
src/Appointment/Booking/Entity/OccupancyBucket.php تضمین یکتاییِ اشغال منبع (فقط مرجع — تغییر نمی‌کند)
src/Resource/Entity/ResourceServiceOffering.php سرویس‌ها و مدت مؤثرِ هر منبع
src/Resource/Entity/ResourceCalendar.php شیفت هفتگی منبع (مبنای اسلات)
migrations/ migration تهی‌پذیر کردن appointments.doctor_id
assets/admin/pages/AppointmentsPage.tsx تب‌ها، مودال ثبت، حذف بخش منابع
assets/admin/components/appointments/DoctorTabs.tsx تب‌ها (باید عمومی شود)
assets/admin/components/appointments/ServiceSlotPicker.tsx انتخاب سرویس/زمان (باید منبع را هم بپذیرد)
assets/admin/components/appointments/ResourceTimeline.tsx + .test.tsx حذف
assets/admin/hooks/useResourceTimeline.ts حذف اگر مصرف‌کنندهٔ دیگری ندارد
docs/api/appointment-booking.md, docs/api/appointment.md, docs/api/resource.md مستندات

وضعیت فعلی

Appointment.php — پزشک اجباری و کلید یکتا بر پایهٔ پزشک:

#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private Doctor $doctor;

public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd)

private function refreshActiveSlotKey(): void
{
    $this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
        ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
        : null;
}

public function getDoctor(): Doctor { return $this->doctor; }

AppointmentController::book() — بدون پزشک رد می‌شود:

if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) {
    $doctorUuid = $resource->subject()->getUuid();
}

if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
    return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 422);
}

$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
    return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}

AppointmentController::serviceSlots() — پزشک‌محور و مقیّد به booking_mode پزشک:

$doctor = $this->doctorRepo->findByUuid($doctorUuid);
if ($doctor === null) {
    return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}

$mode = ($schedule ? $schedule->getMeta() : WeeklySchedule::DEFAULT_META)['booking_mode'] ?? WeeklySchedule::MODE_SLOT;
if ($mode !== WeeklySchedule::MODE_SERVICE) {
    return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این پزشک در حالت نوبت‌دهی سرویسی نیست', 422);
}

AppointmentsPage.tsx — تب فقط پزشک، و بخش منابع که باید حذف شود:

{showDoctorTabs && (
  <DoctorTabs doctors={doctors} selected={selectedDoctorUuid} onSelect={setSelectedDoctorUuid} showAll={isAdmin} />
)}

{/* منابعِ قابل رزرو، زیر همان روز — یک نوبت می‌تواند هم‌زمان اتاق و
    دستگاه را بگیرد و آن است که ظرفیت را تمام می‌کند. */}
<div style={{ marginTop: 'var(--gap)', paddingTop: 'var(--gap)', borderTop: '1px solid var(--border)' }}>
  <h2 className="section-title" style={{ margin: '0 0 12px', fontSize: 15 }}>منابع</h2>
  <ResourceTimeline
    lanes={resourceDay?.resources ?? []}
    dayStart={resourceDay?.date ?? 0}
    loading={resourceTimelineLoading}
    error={resourceTimelineError}
  />
</div>

وظایف

ترتیب اجباری است: بک‌اند اول. وظیفهٔ ۱ پایهٔ بقیه است و اگر ناقص بماند، بقیه روی خرابه ساخته می‌شوند.

۱. تهی‌پذیر کردن Appointment::$doctor

#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
private ?Doctor $doctor = null;

public function __construct(?Doctor $doctor, User $user, int $slotStart, int $slotEnd)

public function getDoctor(): ?Doctor { return $this->doctor; }

/**
 * کلید یکتای اسلات فقط دوباره‌رزروی «همان پزشک» را می‌گیرد. نوبتِ منبع‌محورِ بدون
 * پزشک چنین تداخلی ندارد؛ تداخلِ خودِ منبع را قید یکتای
 * `uniq_bucket_resource_seat` روی `resource_occupancy_buckets` می‌گیرد.
 */
private function refreshActiveSlotKey(): void
{
    $this->activeSlotKey = $this->doctor !== null
        && !$this->isReserve
        && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
            ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
            : null;
}

سپس همهٔ ۷۳ فراخوانی getDoctor() را بررسی کن:

ddev exec grep -rn "getDoctor()" src/ | wc -l     # باید ۷۳ باشد
ddev exec php vendor/bin/phpstan analyse          # سطح ۵ — تهی‌پذیرهای بررسی‌نشده را می‌گیرد

قاعده: هر جا پزشک واقعاً لازم است (تعرفه، برنامهٔ هفتگی، پیامک پزشک) → null را با خطای معنادار رد کن؛ هر جا فقط نمایشی است (toArray() خط ۴۹۹ و ۵۰۳) → مقدار تهی برگردان نه استثنا.

نحوه تست: ddev exec php bin/phpunit tests/Appointment/ باید کامل سبز بماند (رگرسیون مسیر پزشک‌دار). به‌علاوه ddev exec php vendor/bin/phpstan analyse بدون خطای تهی‌پذیری.

۲. Migration

ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction

نحوه تست: بعد از migrate، DESCRIBE appointments; باید doctor_id را YES (nullable) نشان دهد، و ردیف‌های موجود دست‌نخورده بمانند:

ddev exec mysql -e "SELECT COUNT(*) FROM appointments WHERE doctor_id IS NULL;"   # قبل از فیچر: 0

۳. اسلات سرویسیِ منبع‌محور

GET /api/v1/appointment-service-slots را طوری توسعه بده که یا doctor_uuid بگیرد یا resource_uuid — با همان شکل پاسخ.

الگو: Strategy. یک interface مثل ServiceSlotSource با دو پیاده‌سازی DoctorServiceSlotSource و ResourceServiceSlotSource. دلیل انتخاب (guidelines §۵): همین حالا دو پیاده‌سازی واقعی وجود دارد — پزشک از WeeklySchedule و اسلات‌های پزشک می‌آید، منبع از ResourceCalendar + ResourceException + resource_blocks + اشغال. بدون Strategy، این می‌شد یک if روی نوعِ شناسه داخل کنترلر که با هر منبعِ جدید رشد می‌کند.

نکات محاسبه برای منبع:

  • مدت هر سرویس از ResourceServiceOffering همان منبع (مدت مؤثر)، نه از پیش‌فرض سرویس.
  • شرط booking_mode === MODE_SERVICE برای منبع اعمال نشود — آن قید مالِ برنامهٔ هفتگی پزشک است و منبع اصلاً WeeklySchedule ندارد.
  • بازه‌های اشغال از همان مسیری بیاید که تایم‌لاین منبع می‌خواند (ResourceOccupancyRepository)، نه یک کوئری موازیِ جدید.

نحوه تست:

TOKEN=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
  -H 'Content-Type: application/json' \
  -d '{"mobile_number":"0912000201","password":"QaTest@1234"}' | jq -r .access_token)

curl -sk "https://clinic-pro.ddev.site/api/v1/appointment-service-slots?resource_uuid=<UUID>&date=$(date +%F)&management=1&service_item_uuids[]=<SERVICE_UUID>" \
  -H "Authorization: Bearer $TOKEN" | jq
# انتظار: success:true و start_times آرایه‌ای از {start,end,start_time}

۴. رزرو بدون پزشک در book()

  • اگر resource_uuid آمده و منبع پزشک‌پشت ندارد، دیگر به doctor_uuid اصرار نکن.
  • شرط خطا به این تغییر کند: «حداقل یکی از doctor_uuid یا resource_uuid لازم است».
  • بررسی «این منبع این سرویس را ارائه نمی‌دهد» (AppointmentController.php:533-540) باید در مسیر بدون‌پزشک هم اجرا شود — امروز داخل بلوکی است که به $duration وابسته است.
  • بررسی مالکیت محیط منبع (EntityContext::forBooking) وقتی پزشک نداریم باید از محیط کاربر جاری بیاید، نه از پزشک.

نحوه تست: رزرو واقعی روی یک منبع دستگاهی (بدون doctor_uuid) → ۲۰۰؛ سپس همان بازه دوباره → ۴۰۹. هر دو با curl و توکن بالا. و ddev exec mysql -e "SELECT doctor_id, resource_id FROM appointments ORDER BY id DESC LIMIT 1;".

۵. تب منابع در کنار تب پزشکان

DoctorTabs.tsx امروز فقط {uuid, name} می‌گیرد و رفتارش کاملاً عمومی است — همان را عمومی کن (مثلاً EntityTabs) به‌جای ساختن کامپوننت دوم؛ اسم فعلی‌اش تنها چیزِ پزشکیِ آن است (guidelines §۵: اول بگرد، بعد توسعه بده، در آخر بساز).

تب انتخاب‌شده باید در URL بنشیند (useUrlState) وگرنه «بازگشت» نما را می‌پراند — همان قاعده‌ای که در CLAUDE.md برای وضعیت لیست آمده. پیشنهاد: ?tab=doctor:<uuid> و ?tab=resource:<uuid> تا یک کلید هر دو نوع را بگیرد.

نحوه تست: npx vitest run assets/admin/pages/AppointmentsPage.test.tsx + تست جدید: کلیک روی تب یک منبع → فهرست همان منبع؛ رفرش صفحه → همان تب فعال بماند.

۶. مودال ثبت نوبت برای منبع

ServiceSlotPicker باید به‌جای doctorUuid اجباری، یکی از این دو را بگیرد. سرویس‌ها برای منبع از GET /api/v1/resource/{uuid}/services می‌آید (نه از appointment-booking-services/{doctorUuid}).

ترتیب مودال دقیقاً مثل عکس مرجع و مثل حالت پزشک بماند: سرویس‌های منبع → سرویس‌های انتخاب‌شده (با مدت قابل ویرایش) → زمان‌های خالی → جستجوی بیمار → هزینه → ثبت.

نحوه تست: npx vitest run assets/admin/components/appointments/ServiceSlotPicker.test.tsx (تست موجود نباید بشکند) + تست جدید برای حالت منبع. سناریوی UI: تب «لیزر CO2» → افزودن نوبت → یک سرویس → یک زمان → کد ملی بیمار → ثبت → نوبت در فهرست ظاهر شود.

۷. حذف تایم‌لاین منابع

  • بلوک <h2>منابع</h2> + <ResourceTimeline …> از AppointmentsPage.tsx حذف شود.
  • ResourceTimeline.tsx و ResourceTimeline.test.tsx حذف شوند.
  • useResourceTimeline.ts فقط اگر مصرف‌کنندهٔ دیگری ندارد حذف شود:
    grep -rn "useResourceTimeline" assets/admin/
    
  • GET /api/v1/resources/timeline در بک‌اند دست‌نخورده بماند (اندپوینت خودش مشکلی ندارد؛ فقط این مصرف‌کننده حذف می‌شود). اگر بعد از حذف هیچ کلاینتی ندارد، در گزارش پایانی ذکر کن تا کاربر تصمیم بگیرد.

نحوه تست: npx vitest run کامل سبز؛ و اسکرین‌شات صفحه بعد از ddev exec yarn dev که دیگر بخش «منابع» زیر زمان‌بندی ندارد.

۸. مستندات

  • docs/api/appointment-booking.md: resource_uuid بدون doctor_uuid، و اینکه doctor در پاسخ می‌تواند null باشد.
  • docs/api/appointment.md: پارامتر resource_uuid در appointment-service-slots با JSON واقعیِ اجرا (نه دست‌ساز).
  • docs/api/resource.md: اشاره به اینکه سرویس‌های منبع مبنای رزرو منبع‌محورند.

نکات مهم

  • این تغییر cross-repo است. nobat724_front و clinic-pro-tauri هر دو کلاینت همین API‌اند و appointment.doctor را غیرتهی فرض می‌کنند. تغییر قرارداد در build آن‌ها خطا نمی‌دهد و در رانتایم می‌شکند (guidelines §۳). بعد از وظیفهٔ ۱، مصرف واقعی را در nobat724_front/services/response.js و صفحات نوبت دستی دنبال کن و نتیجه را گزارش بده.
  • activeSlotKey را برای نوبت بدون پزشک null بگذار — این حفره نیست؛ تداخل منبع را uniq_bucket_resource_seat می‌گیرد. اگر وسوسه شدی کلید را r<id>:<slot> کنی، اول بررسی کن که با ظرفیت >۱ منبع نمی‌شکند (منبع سه‌ظرفیتی سه رزرو هم‌زمان دارد).
  • Controller نازک بماند: منطق انتخاب اسلات در Service/Strategy، کوئری در Repository، تزریق با constructor injection.
  • خطاها با AppException(ErrorCodes::ERR_XXX) و پیام فارسی؛ پاسخ‌ها با $this->success() / $this->error().
  • تاریخ‌ها Unix timestamp صحیح؛ رشته‌های UI فارسی و تاریخ‌های نمایشی جلالی.
  • اگر وسط کار معلوم شد یکی از ۷۳ فراخوانی getDoctor() نیازمند تصمیم محصولی است (مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، متوقف شو و بپرس — حدس نزن.