Resources now get their own tabs on the appointments page, alongside doctors. An appointment on "Laser CO2" belongs to the device, not to whichever doctor happens to stand behind it, so selecting a resource tab replaces the doctor filter instead of stacking on top of it. GET /api/v1/my/appointments gains an optional resource_uuid filter and returns a `resource` object per row. The join is a leftJoin on purpose: appointments created before the resource-first model have no resource and must not drop out of the list. The resource tab lives in the URL so Back and refresh restore the same view, per the list-state rule in CLAUDE.md. The doctor tab is still useState; moving it is a separate refactor and was left untouched. Verified against the running app: filtering by a resource returns only its appointments, a resource from another tenant returns an empty list (TenantFilter, 200 not 403), and legacy rows still list with resource: null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 KiB
نوبتدهی منبعمحور در صفحهٔ نوبتها — تب هر منبع + رزرو سرویسی + حذف تایملاین منابع
زمینه
صفحهٔ /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()نیازمند تصمیم محصولی است (مثلاً «سهم منشی از نوبت بدون پزشک چطور حساب شود؟»)، متوقف شو و بپرس — حدس نزن.