- Implemented PublicResourceBookingController to handle public resource booking requests. - Added methods for retrieving bookable resources, available slots, and month availability. - Created PublicResourceBookingService to manage public resource offerings and service visibility. - Developed tests for public resource booking to ensure correct functionality and error handling.
24 KiB
نوبتدهی آنلاین منبعمحور — اندپوینتهای عمومی
پروژه
clinicpro (Backend).
پرامپت همتا در سایت عمومی: nobat724_front/.claude/prompt/public-resource-booking-ui.md.
اول این را اجرا کن، بعد آن را. قرارداد API که اینجا ساخته میشود مصرفکنندهٔ مستقیم دارد و
تغییرش در build سایت خطا نمیدهد.
زمینه
منبع (ClinicResource) هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
هر منبع تقویم خودش را دارد و سرویسهایی که ارائه میدهد در resource_service_offerings ثبت شدهاند
(ResourceServiceOffering)، با مدت و قیمتِ اختصاصیِ همان جفتِ منبع↔سرویس.
روی ServiceItem یک توگل به نام bookable هست که در پنل با برچسب «نمایش در نوبتدهی آنلاین»
دیده میشود:
// src/ClinicService/Entity/ServiceItem.php:107
/** نمایش این سرویس در نوبتدهی (پزشک ممکن است همهٔ سرویسها را ارائه ندهد). */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $bookable = false;
امروز این توگل فقط جریانِ پزشکمحورِ سایت را تغذیه میکند
(AppointmentController::bookableServices() → ServiceItemRepository::findBookableByEntity()).
هیچ مسیر عمومیای منابع را نمیبیند.
مشکل / هدف
اسلاتهای منبع فقط از داخل پنل قابل خواندناند:
// src/Resource/Controller/ResourceBookingSlotController.php:26
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ResourceBookingSlotController extends BaseController
{
use ResourcePermissionTrait;
// هر اکشن با denyUnlessGrantedForBooking($user) گیت میشود
ResourceContext::resource($user, $uuid) هم منبع را در محیطِ کاربرِ احرازشده حل میکند، پس برای
بازدیدکنندهٔ ناشناسِ سایت اصلاً قابل استفاده نیست.
هدف: سه اندپوینت عمومیِ جدید تا سایت بتواند ۱) منابعِ قابلِ رزروِ یک پزشک را ببیند، ۲) وقتهای خالیِ یک منبع برای سرویسهای انتخابشده را بگیرد، ۳) روزهای فعالِ ماه را برای تقویم بگیرد. بهعلاوه رفع یک ناسازگاریِ موجود در مسیر عمومیِ ثبت نوبت (وظیفهٔ ۴).
گیتِ عمومیشدن دقیقاً همان توگل است: منبع وقتی در سایت دیده میشود که دستکم یک
ResourceServiceOffering فعال به یک ServiceItem با bookable = true و active = true داشته باشد.
تصمیم دامنه (تأییدشده): روی صفحهٔ یک پزشک فقط منابعی میآیند که پزشکِ ناظرشان
(supervisor) همان پزشک است، یا خودشان پلِ همان پزشکاند (doctor_id). فهرست کلینیکمحور
خارج از این تسک است.
معیار پذیرش
- ✅ موفق:
GET /api/v1/appointment-booking-resources/{doctorUuid}بدون هیچ توکنی → ۲۰۰ با{ success: true, data: { doctor_uuid, clinic_uuid, resources: [...] } }؛ هر منبع فیلدservices[]دارد و در آن فقط سرویسهایbookable=trueوactive=trueباduration_minutesوprice_rialsِ حلشده ازResourceServiceResolverهستند.GET /api/v1/appointment-resource-slots?resource_uuid=..&date=..&service_item_uuids[]=..بدون توکن → ۲۰۰ باstart_times[]که هر عضوش{start, end, start_time, end_time}است، وtotal_duration_minutesبرابرِ مجموعِ مدتِ حلشدهٔ همان منبع.GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=بدون توکن → ۲۰۰ باenabled_dates[]وdisabled_dates[]که مجموعشان همهٔ روزهای آن ماه است.POST /api/v1/appointmentباresource_uuid+service_item_uuids[]+ یکی ازstart_timesِ بالا → ۲۰۱، وslot_endدقیقاً برابرِendِ همان start_time.
- ❌ خطا:
- منبعی که هیچ سرویسِ
bookableندارد → در پاسخِ فهرست نمیآید؛ وappointment-resource-slotsرویش → ۴۲۲ با کدERR_VALIDATION_001وfield: service_item_uuids. - سرویسی که
bookable=falseاست ولی روی منبع offering فعال دارد →resource-slots→ ۴۲۲ (پیام: «این سرویس برای نوبتدهی آنلاین فعال نیست»). یعنی مسیر عمومی سختگیرتر از پنل است. dateبا فرمت نادرست → ۴۲۲ باfield: date.resource_uuidناموجود یاactive=false→ ۴۲۲ باfield: resource_uuid(نه ۴۰۴؛ همان الگوی موجود درAppointmentController).POST /api/v1/appointmentروی بازهای که منبع در آن پر است → ۴۰۹ باfield: resource_uuid(رفتار موجودِResourceOccupier؛ نباید بشکند).
- منبعی که هیچ سرویسِ
- ⚠️ مرزی:
- پزشکی که هیچ منبعی ندارد → ۲۰۰ با
resources: []، نه ۴۰۴. - منبعِ با
capacity > 1: وقتی یک نوبت روی آن نشسته، همان بازه هنوز باید درstart_timesبیاید (ظرفیت هنوز پر نشده).ResourceBookingSlotService::freeIntervals()این را میداند؛ فقط با داده تست شود. - روزی که منبع شیفت ندارد →
start_times: []و همان روز درdisabled_datesماه. service_item_uuidsخالی → ۴۲۲ با پیام «انتخاب حداقل یک سرویس الزامی است» (رفتار موجودِresolveDuration).- آخرین روز ماه شمسی/میلادی:
month-availabilityبر پایهٔ سال/ماهِ میلادی است — همان قراردادappointment-settings/month-availabilityکه سایت از قبل با آن کار میکند. تغییرش نده.
- پزشکی که هیچ منبعی ندارد → ۲۰۰ با
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Resource/Entity/ClinicResource.php |
منبع؛ address, type, supervisor, doctor, capacity, active |
src/Resource/Entity/ResourceServiceOffering.php |
جفتِ منبع↔سرویس با مدت/قیمت/active |
src/Resource/Repository/ClinicResourceRepository.php |
کوئریهای منبع؛ متد جدید اینجا |
src/Resource/Repository/ResourceServiceOfferingRepository.php |
findForResource, bookableResourceIds, activeResourceIdsFor |
src/Resource/Service/ResourceBookingSlotService.php |
resolveDuration, startTimes, freeIntervals, assertOffered |
src/ClinicService/Service/ResourceServiceResolver.php |
زنجیرهٔ حلِ مدت و قیمت |
src/Resource/Controller/ResourceBookingSlotController.php |
نسخهٔ پنلیِ همین اسلاتها — الگوی مرجع |
src/Appointment/Controller/AppointmentController.php |
اندپوینتهای عمومی موجود + POST /api/v1/appointment |
config/packages/security.yaml |
access_control |
docs/api/resource.md، docs/api/appointment.md |
مستندات |
وضعیت فعلی
منبعِ حقیقتِ مدت و قیمت، این زنجیره است:
// src/ClinicService/Service/ResourceServiceResolver.php:41
public function resolve(
ClinicResource $resource,
ServiceItem $item,
DoctorAddress $address,
?ServiceItem $parentService = null,
): ResolvedServiceSpec
// ۱. منبع+گزینه → ۲. منبع+سرویس → ۳. شعبه → ۴. پیشفرض سرویس
و اسلاتها:
// src/Resource/Service/ResourceBookingSlotService.php:87
public function startTimes(
ClinicResource $resource,
string $date,
int $totalMinutes,
?int $excludeAppointmentId = null,
): array
مسیر عمومیِ ثبت نوبت، مدت را از calculatorِ پزشکمحور میگیرد — حتی وقتی منبع دارد:
// src/Appointment/Controller/AppointmentController.php:528-538
$duration = null;
if ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
if ($resource !== null) {
[$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair();
// ...
مسیر پنل همین را درست انجام میدهد و باید الگو باشد:
// src/Appointment/Controller/MyAppointmentsController.php:188-197
if (!empty($serviceUuids) && !$isReserve && $resource !== null) {
// نوبتِ منبع: مدت از زنجیرهٔ حلِ همان منبع میآید (هر دستگاه مدت خودش را
// دارد) و گیتِ «این سرویس روی این منبع فعال است؟» جای `bookable` مینشیند.
['minutes' => $resourceMinutes, 'items' => $serviceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids, $durationOverrides);
وظایف
۱. کوئری منابعِ قابلِ رزروِ عمومی
در src/Resource/Repository/ClinicResourceRepository.php متد جدید:
/**
* منابعِ یک پزشک در یک محیط که در سایت عمومی قابل رزروند.
*
* سه شرط با هم: منبع فعال، دستکم یک offering فعال، و سرویسِ آن offering هم
* `bookable` و هم `active`. توگلِ «نمایش در نوبتدهی آنلاین» تنها گیتِ عمومیشدن است؛
* منبعی که سرویسِ روشنی ندارد اصلاً نباید در پاسخ دیده شود.
*
* @return ClinicResource[]
*/
public function findPublicBookableForDoctor(string $entityType, int $entityId, Doctor $doctor): array
{
return $this->createQueryBuilder('r')
->join('r.type', 't')
->addSelect('t')
->join(ResourceServiceOffering::class, 'o', 'WITH', 'o.resource = r')
->join('o.serviceItem', 'i')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->andWhere('r.active = true')
->andWhere('o.active = true')
->andWhere('i.bookable = true')
->andWhere('i.active = true')
// فقط منابع همین پزشک: یا خودش پلِ پزشک است، یا پزشک ناظرش همین است.
->andWhere('r.doctor = :doctor OR r.supervisor = :doctor')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('doctor', $doctor)
->distinct()
->orderBy('r.name', 'ASC')
->getQuery()
->getResult();
}
نحوه تست: یک unit/functional تست که سه سناریو بسازد — منبع با سرویسِ bookable (باید بیاید)،
منبع با سرویسِ bookable=false (نباید بیاید)، منبع با offering غیرفعال (نباید بیاید) — و منبعی
که ناظرش پزشک دیگری است (نباید بیاید).
۲. سرویسِ ساختِ payload عمومی
فایل جدید src/Resource/Service/PublicResourceBookingService.php.
کنترلر نازک میماند؛ این کلاس تنها مسئولیتش «منبع → آرایهٔ عمومی» است (SRP).
/**
* نمای عمومیِ منبع برای سایت نوبتدهی.
*
* از نمای پنل جداست چون سؤالِ دیگری جواب میدهد: پنل همهٔ سرویسهای منبع را
* میخواهد، سایت فقط آنهایی را که مالک روشن کرده. ادغامشان یعنی یک `if ($public)`
* در دلِ کد پنل و یک راهِ تازه برای نشتِ سرویسِ خاموش.
*/
final class PublicResourceBookingService
{
public function __construct(
private readonly ClinicResourceRepository $resources,
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ResourceServiceResolver $resolver,
) {}
/** @return array<int, array<string, mixed>> */
public function resourcesFor(Doctor $doctor, ?Clinic $clinic): array;
/**
* سرویسهای عمومیِ یک منبع — همان گیتِ فهرست، تا اسلات و فهرست از هم واگرا نشوند.
*
* @return array<int, array<string, mixed>>
*/
public function publicServices(ClinicResource $resource): array;
/** @throws AppException ۴۲۲ روی سرویسی که در سایت روشن نیست */
public function assertPublicService(ClinicResource $resource, ServiceItem $item): void;
}
شکل هر منبع در پاسخ:
[
'uuid' => $resource->getUuid(),
'name' => $resource->getName(),
'type' => ['code' => $type->getCode(), 'name' => $type->getName()],
'capacity' => $resource->getCapacity(),
'location' => [
'uuid' => $address->getUuid(),
'title' => $address->getName() ?: 'مطب شخصی',
'address' => $address->getAddress(),
],
'supervisor' => ['uuid' => ..., 'full_name' => ...] | null,
'services' => [
[
'uuid' => $item->getUuid(),
'name' => $item->getName(),
'duration_minutes' => $spec->durationMinutes, // از resolver، نه از پیشفرض خام
'price_rials' => $spec->priceRials,
'service_section' => ['uuid' => ..., 'name' => ...],
],
],
]
نکتهٔ کارایی: publicServices() نباید به ازای هر سرویس یک findOneFor جدا بزند وقتی
findForResource($resource) همه را یکجا میدهد. offeringهای همان منبع را یکبار بخوان و
سرویسهای bookable && active را از رویش فیلتر کن؛ resolver->resolve() را فقط برای همانها صدا بزن.
نحوه تست: ddev exec php bin/phpunit روی یک تستِ سرویس با دو سرویس (یکی روشن، یکی خاموش)
و یک offering با duration_minutes اختصاصی؛ ادعا: خروجی یک عضو دارد و مدتش عددِ offering است،
نه solo_duration_minutesِ سرویس.
۳. کنترلر عمومی + مسیرها
فایل جدید src/Resource/Controller/PublicResourceBookingController.php — extends BaseController،
بدون IsGranted و بدون ResourcePermissionTrait.
/**
* نوبتدهی منبعمحور برای سایت عمومی.
*
* از {@see ResourceBookingSlotController} جداست و نه یک پرچمِ `public` روی آن: آنجا منبع
* از محیطِ کاربرِ احرازشده حل میشود (`ResourceContext::resource($user, $uuid)`) و اینجا
* کاربری وجود ندارد. یک کنترلر با دو مدلِ اعتماد، همانجایی است که نشت اتفاق میافتد.
*/
#[OA\Tag(name: 'Resource')]
class PublicResourceBookingController extends BaseController
{
// GET /api/v1/appointment-booking-resources/{doctorUuid}?clinic_uuid=
// GET /api/v1/appointment-resource-slots?resource_uuid=&date=&service_item_uuids[]=
// GET /api/v1/appointment-resource-month-availability/{resourceUuid}?year=&month=&service_item_uuids[]=
}
قواعدی که باید رعایت شوند:
- محیط با همان الگوی موجود حل شود:
[$type, $id] = EntityContext::forBooking($doctor, $clinic)->toEntityPair();و$clinicازAppointmentController::bookingClinic()— اگر متدprivateاست، منطقش را کپی نکن؛ یا در یک سرویس مشترک بگذار یا ازBookingContextResolverاستفاده کن. تصمیم و دلیلش را در کامنت بنویس. - در
resource-slotsوmonth-availabilityمنبع باClinicResourceRepository::findByUuid()گرفته میشود و باید بررسی شود:isActive()و اینکه دستکم یک سرویس عمومی دارد. منبعِ ناموجود یا خاموش → ۴۲۲ باfield: resource_uuid. - برای هر uuid در
service_item_uuidsاولassertPublicService()صدا زده شود (گیتِbookable)، بعدResourceBookingSlotService::resolveDuration()(گیتِ offering + مدت). ترتیب مهم است: پیامِ «در سایت فعال نیست» گویاتر از «این منبع این سرویس را ارائه نمیدهد» است. durations[]که نسخهٔ پنلی میپذیرد در مسیر عمومی پذیرفته نشود — override مدت ابزار منشی است؛ در دست بازدیدکننده یعنی ساختن ظرفیتِ جعلی. یعنیresolveDuration($resource, $uuids)بدون آرگومان سوم.month-availabilityروی روزهای ماه حلقه بزند و برای هر روزstartTimes(...) !== []را بسنجد — همان الگویAppointmentController::monthAvailability()که باhasAnyAvailabilityکار میکند. اگر روی ۳۱ روز کند بود، بهجایstartTimesازfreeIntervalsاستفاده کن و فقط وجودِ یک بازهٔ بهاندازهٔ کافی بلند را چک کن.
سپس در config/packages/security.yaml، بخش access_control، کنار همتاهای موجود:
- { path: ^/api/v1/appointment-booking-resources/, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-resource-slots, roles: PUBLIC_ACCESS }
- { path: ^/api/v1/appointment-resource-month-availability/, roles: PUBLIC_ACCESS }
مسیرها روی firewallِ api میمانند (به public_endpoints اضافه نشوند) — دقیقاً به همان
دلیلی که در کامنت بالای public_endpoints نوشته شده: توکن اختیاری بماند.
نحوه تست:
ddev exec php bin/console debug:router | grep resource
# سه مسیر جدید باید دیده شوند
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-booking-resources/<DOCTOR_UUID>" | jq
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-slots?resource_uuid=<R>&date=2026-08-15&service_item_uuids[]=<S>" | jq
curl -s "https://clinic-pro.ddev.site/api/v1/appointment-resource-month-availability/<R>?year=2026&month=8&service_item_uuids[]=<S>" | jq
هر سه بدون هدر Authorization و همه باید ۲۰۰ بدهند. سپس همان curlها را روی سرویسی بزن که
bookable را در پنل خاموش کردهای و ۴۲۲ بگیر.
اکانت تست پنل برای ساختن داده: 09390039833 / 09390039833.
۴. رفعِ مدتِ نوبتِ منبعدار در مسیر عمومی
در src/Appointment/Controller/AppointmentController.php::book()، شاخهٔ محاسبهٔ مدت باید مثل
مسیر پنل، وقتی منبع هست از ResourceBookingSlotService::resolveDuration() استفاده کند:
$duration = null;
$resourceItems = [];
if ($hasServices && $resource !== null) {
// مدت از زنجیرهٔ حلِ همان منبع میآید، وگرنه `slot_end` با start_timesِ
// appointment-resource-slots یکی نمیشود و بیمار وقتی را میگیرد که سرور
// جای دیگری آزاد حساب کرده بود.
foreach ($serviceUuids as $u) { /* assertPublicService(...) */ }
['minutes' => $minutes, 'items' => $resourceItems] =
$this->resourceSlots->resolveDuration($resource, $serviceUuids);
$slotEnd = $slotStart + $minutes * 60;
} elseif ($hasServices) {
$duration = $this->serviceCalculator->calculate($doctor, $bookingClinic, $serviceUuids);
$slotEnd = $duration->endFor($slotStart);
}
سه قید:
- ترازِ سطلِ اشغال (
OccupancyBucket::alignWindow) در خط ۵۱۵ قبل از محاسبهٔ مدت اجرا میشود و در آنجا$slotEndهنوز مقدارِ کلاینت است. بعد از بازنویسیِ$slotEnd، تراز باید دوباره اعمال شود؛ وگرنه بازهٔ نهایی ناتراز میماند. - ذخیرهٔ سرویسها روی نوبت (خط ~۶۰۵،
replaceServiceItems+setServiceDuration) نباید بشکند. در شاخهٔ منبع،$durationتهی است، پس این بلوک بایدitemsوminutesِ منبع را هم بپذیرد. - بررسیِ موجودِ «این منبع این سرویس را ارائه میدهد؟» (خط ۵۵۸ با
hasAnyFor/activeResourceIdsFor) حالا باresolveDurationتکراری میشود. یکی را نگه دار —resolveDurationسختگیرتر است چونoffering.activeرا هم میبیند. حذفِ کدِ تکراری را در همان commit توضیح بده.
نحوه تست: یک تست functional که:
۱) از appointment-resource-slots یک start_time بگیرد،
۲) با همان start و همان service_item_uuids روی POST /api/v1/appointment بزند،
۳) ادعا کند slot_end پاسخ برابر endِ همان start_time است.
قبل از این تغییر باید قرمز شود.
۵. مستندات
docs/api/resource.md: سه اندپوینت عمومی جدید با نمونهٔ درخواست/پاسخ و جدول خطاها.docs/api/appointment.md: رفتارresource_uuidدرPOST /api/v1/appointment— که مدت از منبع میآید و در مسیر عمومی سرویس بایدbookableباشد.- اگر فایل
docs/api/appointment-booking.mdجریان عمومی را توصیف میکند، مرحلهٔ منبعمحور را هم آنجا اضافه کن.
نکات مهم
- گیتِ عمومی یک جا تعریف شود. «سرویس در سایت دیده میشود» =
bookable && active+ offering فعال. این شرط در سه جا لازم است (فهرست، اسلات، ثبت نوبت). یک متد درPublicResourceBookingServiceو صدا زدنش از هر سه — نه سهبار نوشتنِ همانif. - جداسازی محیط: منبع با uuid از بیرون میآید و
TenantFilterپوششش نمیدهد (ResourceServiceOfferingفرزندِ aggregate است). در هر سه اندپوینت، تطابقِ(entity_type, entity_id)ِ منبع با محیطِ رزروِ همان پزشک/کلینیک باید صریح بررسی شود — همان کاری کهAppointmentControllerدر خط ۵۴۹ میکند. - fail-safe عمومی: پارامتر
management=1در این کنترلر معنا ندارد و پیاده نشود. اگر روزی پنل بخواهد همین دید را داشته باشد، مسیر پنلیِ خودش را دارد. durations[]در مسیر عمومی ممنوع — دلیلش بالا آمده.- ظرفیت و اشغال را خودت حساب نکن؛
ResourceBookingSlotServiceهر دو منبعِ اشغال را میبیند (appointments.resource_idوresource_occupancy). دور زدنش یعنی نوبتِ نامرئی. - تاریخها Unix timestamp صحیحاند؛
start_time/end_timeرشتهٔH:iبه وقتِ محلیِ شعبهٔ منبع است (ResourceBookingSlotService::dayStart()این را ازaddress->getTimezone()میگیرد). - مصرفکنندهٔ cross-repo: این قرارداد را
nobat724_front/services/response.jsمصرف میکند. هر تغییر در نام فیلدها بعد از این، در build سایت خطا نمیدهد — پس شکل پاسخ را قبل از merge نهایی کن.