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>
23 KiB
نوبتدهی منبعمحور در صفحهٔ نوبتها — تب هر منبع + رزرو سرویسی + حذف تایملاین منابع
وضعیت: انجام شد (۱۴۰۵/۰۵/۱۲). کامیتها:
3e3a2482(تب منابع + فیلتر)،fd27ceef(مودال رزرو + حذف تایملاین).این پرامپت در اجرا سهبار غلط از آب درآمد — چیزی که واقعاً شد:
۱. «نوبت بدون پزشک» پیاده شد و بعد کاملاً برگردانده شد.
doctorتهیپذیر شد و migration اجرا شد، ولی وسط کار معلوم شد مدل مجوز منشی روی سهتایی(منشی، کلینیک، پزشک)بنا شده وDoctorSecretary.doctorتهیپذیر نیست — یعنی برای نوبتِ بدون پزشک هیچ ردیف مجوزی وجود ندارد. تصمیم کاربر: منبع پزشکِ مسئول داشته باشد. migration رولبک و کدgit checkoutشد.۲. «۷۳ فراخوانی getDoctor()» بیشبرآورد بود. بیشترشان روی entityهای دیگرند (
WeeklySchedule،Holiday،Rate،DoctorAddress). عدد واقعی رویAppointmentحدود ۲۵ بود، و diff سطح ۸ دقیقاً ۳۳ نقطهٔ جدید در ۱۶ فایل داد.۳. مهمترین: کل موتور از قبل ساخته شده بود و این پرامپت از وجودش بیخبر بود.
POST /api/v1/appointment-availability(باassignmentper اسلات)،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_uuid→422با 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()نیازمند تصمیم محصولی است (مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، متوقف شو و بپرس — حدس نزن.