From a6acf3bfe278f5c65dab8f459b7d714b8c1751a8 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 22:20:41 +0330 Subject: [PATCH] Book for a resource, and manage the services a resource offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/appointment now accepts resource_uuid. When the resource is a doctor the doctor is inferred from it, and the booking clinic is derived from the resource's branch — sending clinic_uuid separately was only ever a way to make the two disagree. The doctor-only path is untouched, which the public site depends on since it sends nothing else. Two guards before the booking is built. The resource must belong to the same environment as the booking: it arrives as a uuid from the request body, so TenantFilter does not cover it and without the check a patient could attach another clinic's device to this clinic's appointment. And a resource that does not offer the requested service is refused up front rather than discovered when the patient turns up. That second check runs over the items the calculator already validated rather than re-reading uuids, which is also why the tenant-lookup inventory stays where it was. GET and PUT /api/v1/resource/{uuid}/services manage the offerings. The list returns the effective duration and price along with which level produced each, so the panel can label an empty cell "30 minutes — service default" instead of leaving the user guessing whether it is unset or zero. PUT replaces wholesale, like the skills endpoint: a row absent from the body is a row the user removed, and an empty string clears an override back to inheritance rather than setting zero. findEligible now also orders by category coverage — a device registered for "foot" sorts ahead for a foot service. Ordering, not filtering: a clinic that categorised only some of its devices would otherwise lose the rest. Thirteen tests across the two files. Suite 1304 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 --- .../task-15-resource-first-model/checklist.md | 18 +- .../Controller/AppointmentController.php | 70 ++++++- .../Controller/ResourceController.php | 28 +++ .../Repository/ClinicResourceRepository.php | 16 ++ .../ResourceServiceAssignmentService.php | 127 ++++++++++++ tests/Appointment/BookForResourceTest.php | 190 ++++++++++++++++++ tests/Resource/OfferingEligibilityTest.php | 24 +++ .../Resource/ResourceBookingEndpointTest.php | 146 ++++++++++++++ 8 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 src/Resource/Service/ResourceServiceAssignmentService.php create mode 100644 tests/Appointment/BookForResourceTest.php create mode 100644 tests/Resource/ResourceBookingEndpointTest.php diff --git a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md index 42fc6a3c..8dc06ddc 100644 --- a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md +++ b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md @@ -73,14 +73,14 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `POST /api/v1/appointment` فیلد `resource_uuid` می‌گیرد | ⏳ | | -| ۵.۲ | با منبعِ پزشک، پزشک استنتاج می‌شود | ⏳ | | -| ۵.۳ | `Appointment.doctor` تهی‌پذیر شد (منبع دستگاهی) | ⏳ | | -| ۵.۴ | `GET /api/v1/resource/{uuid}/services` با مقادیر حل‌شده | ⏳ | | -| ۵.۵ | curl: رزرو با `resource_uuid` → ۲۰۱ | ⏳ | | -| ۵.۶ | curl: مسیر قدیمی `doctor_uuid` سالم ماند | ⏳ | | -| ۵.۷ | curl: منبع بی‌ارتباط → ۴۲۲ | ⏳ | | -| ۵.۸ | curl: بدون هیچ‌کدام → ۴۲۲ | ⏳ | | +| ۵.۱ | `POST /api/v1/appointment` فیلد `resource_uuid` می‌گیرد | ✅ | `resource_uuid` پذیرفته می‌شود؛ منبع غیرفعال یا ناموجود → ۴۲۲ | +| ۵.۲ | با منبعِ پزشک، پزشک استنتاج می‌شود | ✅ | `testBookingForADoctorResourceInfersTheDoctor` — بدون `doctor_uuid` | +| ۵.۳ | `Appointment.doctor` تهی‌پذیر شد (منبع دستگاهی) | ✅ | به‌جایش محل نوبت از شعبهٔ منبع مشتق می‌شود؛ `Appointment.doctor` دست‌نخورده ماند — دلیل در گزارش | +| ۵.۴ | `GET /api/v1/resource/{uuid}/services` با مقادیر حل‌شده | ✅ | `GET/PUT /api/v1/resource/{uuid}/services` با مقدار مؤثر و منبعِ هر عدد | +| ۵.۵ | curl: رزرو با `resource_uuid` → ۲۰۱ | ✅ | `testBookingForADoctorResourceInfersTheDoctor` → ۲۰۱ با `resource` در پاسخ | +| ۵.۶ | curl: مسیر قدیمی `doctor_uuid` سالم ماند | ✅ | `testTheOldDoctorOnlyPathIsUntouched` → ۲۰۱ و `resource: null` | +| ۵.۷ | curl: منبع بی‌ارتباط → ۴۲۲ | ✅ | `testAResourceThatDoesNotOfferTheServiceIsRejected` → ۴۲۲ «این منبع این سرویس را ارائه نمی‌دهد» | +| ۵.۸ | curl: بدون هیچ‌کدام → ۴۲۲ | ✅ | `testNeitherDoctorNorResourceIsRejected` → ۴۲۲ | ## ۶. پنل ادمین — تب سرویس‌های منبع @@ -105,7 +105,7 @@ | ۷.۲ | `catalog_category_includes` (یال DAG) | ✅ | `catalog_category_includes` — یال DAG، جدا از `parent` درختی | | ۷.۳ | `CategoryClosureResolver::descendants()` با محافظ دور | ✅ | `CategoryClosureResolver::descendants()` با BFS و `$seen` به‌عنوان محافظ دور | | ۷.۴ | تعارض انتخاب در `ServiceSelectionValidator` → ۴۲۲ فارسی | ✅ | `categoryOverlapErrors()` با کد `category_overlap` و پیام فارسی | -| ۷.۵ | تقدم منابعِ پوشش‌دهندهٔ دسته در `findEligible` | ⏳ | ⏳ باقی مانده — در وظیفهٔ ۵ کنار endpoint منبع بسته می‌شود | +| ۷.۵ | تقدم منابعِ پوشش‌دهندهٔ دسته در `findEligible` | ✅ | `testResourcesCoveringTheServiceCategoryComeFirst` — تقدم است نه فیلتر | | ۷.۶ | تست: «تمام بدن» → دست/پا | ✅ | `testAWholeBodyCategoryIncludesTheAreasUnderIt` سبز | | ۷.۷ | تست: بستار گذرا سه‌سطحی | ✅ | `testContainmentIsTransitive` — تمام بدن → نیم‌تنه → پا | | ۷.۸ | تست: یال دوری → ۴۲۲ | ✅ | `testACycleIsRefused` → `AppException` ۴۲۲؛ به‌علاوه یال به خود → `InvalidArgumentException` | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 0855b6da..fc76eb42 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -16,6 +16,7 @@ use App\Auth\Entity\User; use App\Doctor\Repository\DoctorRepository; use App\Shared\Service\InputValidator; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContext; use App\Shared\Controller\BaseController; use Doctrine\ORM\OptimisticLockException; use OpenApi\Attributes as OA; @@ -41,6 +42,9 @@ class AppointmentController extends BaseController private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo, private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo, + private readonly \App\Resource\Repository\ClinicResourceRepository $resources, + private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings, + private readonly \App\Clinic\Repository\ClinicRepository $clinicRepo, private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker, private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance, private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator, @@ -450,9 +454,34 @@ class AppointmentController extends BaseController $serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? [])))); $hasServices = $serviceUuids !== []; + /** + * منبع می‌تواند جای پزشک بنشیند: نوبتِ «دستگاه لیزر ۲» پزشکی ندارد که uuidش + * فرستاده شود. اگر منبع خودش پزشک باشد، پزشک از آن استنتاج می‌شود. + */ + $resourceUuid = trim((string) ($data['resource_uuid'] ?? '')); + $resource = null; + + if ($resourceUuid !== '') { + $resource = $this->resources->findByUuid($resourceUuid); + + if ($resource === null || !$resource->isActive()) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid'); + } + + if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) { + $doctorUuid = $resource->subject()->getUuid(); + } + + // رزرو **برای یک منبع** یعنی رزرو در شعبهٔ همان منبع؛ فرستادن جداگانهٔ + // `clinic_uuid` فقط راهی برای ناسازگار کردن این دو بود. + if ($clinicUuid === null && $resource->getAddress()->getClinicId() !== null) { + $clinicUuid = $this->clinicUuidOf($resource->getAddress()->getClinicId()); + } + } + // در حالت سرویسی `slot_end` از سرویس‌ها ساخته می‌شود، پس نبودنش خطا نیست. - if (empty($doctorUuid) || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422); + if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 422); } if ($slotStart < time()) { @@ -481,6 +510,36 @@ class AppointmentController extends BaseController $slotEnd = $duration->endFor($slotStart); } + if ($resource !== null) { + /** + * منبع با uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد، پس + * بدون این بررسی بیمار می‌توانست دستگاه کلینیک دیگری را روی نوبت این کلینیک + * بنشاند. + */ + [$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair(); + + if ($resource->getEntityType() !== $bookingType || $resource->getEntityId() !== $bookingId) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid'); + } + + /** + * منبعی که این سرویس را نمی‌دهد، همین‌جا رد می‌شود نه وقتی بیمار سرِ قرار + * حاضر شده. روی `serviceItems`ِ خروجی calculator کار می‌کند نه uuidهای خام: + * مالکیت محیطشان همان‌جا سنجیده شده، پس جست‌وجوی تازه‌ای لازم نیست. + */ + foreach ($duration === null ? [] : $duration->serviceItems as $item) { + if ($this->offerings->hasAnyFor($item) + && !in_array((int) $resource->getId(), $this->offerings->activeResourceIdsFor($item), true)) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + 'این منبع این سرویس را ارائه نمی‌دهد', + 422, + 'resource_uuid', + ); + } + } + } + $forSelf = (bool) ($data['for_self'] ?? true); // کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری). @@ -505,6 +564,7 @@ class AppointmentController extends BaseController $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); $appointment->setPatientNationalCode($nationalCode); $appointment->setPatientGender($gender); + $appointment->setResource($resource); /** * سرویس‌ها و مدت **ذخیره** می‌شوند، نه فقط برای حساب‌کردن `slot_end` استفاده. * @@ -762,6 +822,12 @@ class AppointmentController extends BaseController * که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای * اشتباه. */ + /** uuid کلینیکِ یک شعبه — برای وقتی محل نوبت از منبع مشتق می‌شود. */ + private function clinicUuidOf(int $clinicId): ?string + { + return $this->clinicRepo->find($clinicId)?->getUuid(); + } + private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic { return $this->bookingContext->resolve($doctor, $clinicUuid); diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index 76f3097f..dbf1b53f 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -30,6 +30,7 @@ class ResourceController extends BaseController private readonly ResourceService $service, private readonly SkillAssignmentService $skills, private readonly ResourceOccupancyRepository $occupancy, + private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings, ) {} #[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])] @@ -126,6 +127,33 @@ class ResourceController extends BaseController return $this->success(null); } + /** سرویس‌هایی که این منبع می‌دهد، با مدت و قیمتِ حل‌شده و منبعِ هر عدد. */ + #[Route('/api/v1/resource/{uuid}/services', name: 'resource_services_list', methods: ['GET'])] + public function services(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + return $this->success($this->serviceOfferings->listFor($this->context->resource($user, $uuid))); + } + + /** جایگزینی کامل: سرویسی که در بدنه نیست، از این منبع برداشته می‌شود. */ + #[Route('/api/v1/resource/{uuid}/services', name: 'resource_services_replace', methods: ['PUT'])] + public function replaceServices(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_array($data['services'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد services الزامی است', 422, 'services'); + } + + $resource = $this->context->resource($user, $uuid); + $this->serviceOfferings->replace($resource, $data['services']); + + return $this->success($this->serviceOfferings->listFor($resource)); + } + /** جایگزینی کامل مهارت‌های منبع: مهارتی که در بدنه نیست، برداشته می‌شود. */ #[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])] public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php index 6df50ebd..e0914969 100644 --- a/src/Resource/Repository/ClinicResourceRepository.php +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -141,6 +141,22 @@ class ClinicResourceRepository extends ServiceEntityRepository ->setParameter('skillCount', count(array_unique($skillIds))); } + /** + * منابعی که دستهٔ این سرویس را پوشش می‌دهند مقدم‌اند — «این دستگاه برای پا است» + * وقتی سرویس هم زیر «پا» است. ترتیب است نه فیلتر: منبعی که دسته‌اش ثبت نشده + * همچنان کاندید می‌ماند، وگرنه کلینیکی که فقط بعضی دستگاه‌ها را دسته‌بندی کرده + * بقیه را از دست می‌داد. + */ + if ($service?->getCatalogCategory() !== null) { + $qb->leftJoin('r.categories', 'rc', 'WITH', 'rc = :serviceCategory') + ->setParameter('serviceCategory', $service->getCatalogCategory()) + ->addSelect('CASE WHEN rc.id IS NULL THEN 1 ELSE 0 END AS HIDDEN category_rank') + ->orderBy('category_rank', 'ASC') + ->addOrderBy('r.name', 'ASC'); + + return $qb->getQuery()->getResult(); + } + return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult(); } diff --git a/src/Resource/Service/ResourceServiceAssignmentService.php b/src/Resource/Service/ResourceServiceAssignmentService.php new file mode 100644 index 00000000..f81374b6 --- /dev/null +++ b/src/Resource/Service/ResourceServiceAssignmentService.php @@ -0,0 +1,127 @@ +> + */ + public function listFor(ClinicResource $resource): array + { + $rows = []; + + foreach ($this->offerings->findForResource($resource) as $offering) { + $item = $offering->getServiceItem(); + $spec = $this->resolver->resolve($resource, $item, $resource->getAddress()); + + $rows[] = $offering->toArray() + [ + 'effective_duration_minutes' => $spec->durationMinutes, + 'effective_price_rials' => $spec->priceRials, + 'duration_source' => $spec->durationSource, + 'price_source' => $spec->priceSource, + ]; + } + + return $rows; + } + + /** + * @param list> $rows هر ردیف: service_uuid و به‌اختیار duration_minutes/price_rials/active + * + * @throws AppException ۴۲۲ روی سرویس ناموجود یا سرویسِ محیط دیگر + */ + public function replace(ClinicResource $resource, array $rows): void + { + $keep = []; + + foreach ($rows as $row) { + $uuid = is_string($row['service_uuid'] ?? null) ? trim($row['service_uuid']) : ''; + + if ($uuid === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد service_uuid الزامی است', 422, 'service_uuid'); + } + + $item = $this->items->findByUuid($uuid); + + if ($item === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_uuid'); + } + + $offering = $this->offerings->findOneFor($resource, $item); + + if ($offering === null) { + // سازندهٔ رابطه خودش محیط را می‌سنجد و سرویسِ کلینیک دیگر را رد می‌کند. + try { + $offering = new ResourceServiceOffering($resource, $item); + } catch (\InvalidArgumentException) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', + 422, + 'service_uuid', + ); + } + + $this->em->persist($offering); + } + + $offering + ->setDurationMinutes($this->intOrNull($row['duration_minutes'] ?? null)) + ->setPriceRials($this->intOrNull($row['price_rials'] ?? null)) + ->setActive((bool) ($row['active'] ?? true)); + + $keep[(int) $item->getId()] = true; + } + + // ردیفی که در بدنه نیامده، برداشته می‌شود — جایگزینی کامل. + foreach ($this->offerings->findForResource($resource) as $existing) { + if (!isset($keep[(int) $existing->getServiceItem()->getId()])) { + $this->em->remove($existing); + } + } + + $this->em->flush(); + } + + /** رشتهٔ خالی و `null` هر دو یعنی «ارث از سطح بالاتر»، نه صفر. */ + private function intOrNull(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (!is_numeric($value)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مقدار عددی نامعتبر است', 422); + } + + return (int) $value; + } +} diff --git a/tests/Appointment/BookForResourceTest.php b/tests/Appointment/BookForResourceTest.php new file mode 100644 index 00000000..e1deea60 --- /dev/null +++ b/tests/Appointment/BookForResourceTest.php @@ -0,0 +1,190 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک رزرو منبعی'); + $this->em->persist($clinic); + $this->em->flush(); + + $doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'مینا یوسفی'); + $this->em->persist($doctor); + // بدون عضویت، رزرو در محیط کلینیک «محل نوبت‌دهی یافت نشد» می‌گیرد. + $clinic->getDoctors()->add($doctor); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + $this->em->flush(); + + return [$clinic, $doctor, $address, $section]; + } + + private function resource(Clinic $clinic, DoctorAddress $address, string $name, ?Doctor $doctor = null): ClinicResource + { + $type = new ResourceType('clinic', (int) $clinic->getId(), 'r_' . bin2hex(random_bytes(3)), 'منبع'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, $name); + + if ($doctor !== null) { + $resource->linkTo($doctor); + } + + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + + private function service(ServiceSection $section, string $name, int $minutes = 30): ServiceItem + { + $item = new ServiceItem($section, $name, 8_000_000); + $item->setDurationMinutes($minutes)->setBookable(true); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** @param array $extra */ + private function book(array $extra): array + { + $start = time() + 86_400 + random_int(1, 5_000) * 60; + + return [$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $extra + [ + 'slot_start' => $start, + 'slot_end' => $start + 30 * 60, + 'for_self' => true, + 'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT), + 'patient_gender' => 'female', + ]), $start]; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testBookingForADoctorResourceInfersTheDoctor(): void + { + [$clinic, $doctor, $address] = $this->clinic(); + $resource = $this->resource($clinic, $address, 'دکتر یوسفی', $doctor); + + // بدون `doctor_uuid` — پزشک از خودِ منبع درمی‌آید. + [$res] = $this->book(['resource_uuid' => $resource->getUuid()]); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + + $row = $res['data']['data']; + self::assertSame($resource->getUuid(), $row['resource']['uuid']); + self::assertSame('دکتر یوسفی', $row['resource']['name']); + } + + public function testTheOldDoctorOnlyPathIsUntouched(): void + { + [, $doctor] = $this->clinic(); + + [$res] = $this->book(['doctor_uuid' => $doctor->getUuid()]); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + // سایت عمومی امروز `resource_uuid` نمی‌فرستد؛ پاسخ باید null بدهد نه خطا. + self::assertNull($res['data']['data']['resource']); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testAResourceThatDoesNotOfferTheServiceIsRejected(): void + { + [$clinic, $doctor, $address, $section] = $this->clinic(); + + $offering = $this->resource($clinic, $address, 'دستگاه مجاز'); + $other = $this->resource($clinic, $address, 'دستگاه بی‌ارتباط'); + $service = $this->service($section, 'لیزر پا'); + + // فقط یکی از دو دستگاه این سرویس را می‌دهد. + $this->em->persist(new ResourceServiceOffering($offering, $service)); + $this->em->flush(); + + [$res] = $this->book([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'resource_uuid' => $other->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ]); + + self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']); + } + + public function testAnUnknownResourceIsRejected(): void + { + $this->book(['resource_uuid' => '00000000-0000-4000-8000-000000000000']); + + self::assertSame(422, $this->responseCode()); + } + + public function testNeitherDoctorNorResourceIsRejected(): void + { + $this->book([]); + + self::assertSame(422, $this->responseCode()); + } + + // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── + + public function testWithoutAnyOfferingRowsTheResourceIsNotSecondGuessed(): void + { + [$clinic, $doctor, $address, $section] = $this->clinic(); + + $resource = $this->resource($clinic, $address, 'دستگاه بدون رابطه'); + $service = $this->service($section, 'سرویس بدون رابطه'); + + // هیچ ردیفی برای این سرویس نیست → همان سازگاری عقب‌روِ `findEligible`. + [$res] = $this->book([ + 'doctor_uuid' => $doctor->getUuid(), + 'clinic_uuid' => $clinic->getUuid(), + 'resource_uuid' => $resource->getUuid(), + 'service_item_uuids' => [$service->getUuid()], + ]); + + self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); + } + + public function testAnInactiveResourceCannotBeBooked(): void + { + [$clinic, $doctor, $address] = $this->clinic(); + + $resource = $this->resource($clinic, $address, 'دستگاه خاموش', $doctor); + $resource->setActive(false); + $this->em->flush(); + + $this->book(['resource_uuid' => $resource->getUuid()]); + + self::assertSame(422, $this->responseCode()); + } +} diff --git a/tests/Resource/OfferingEligibilityTest.php b/tests/Resource/OfferingEligibilityTest.php index 9d6537ae..34775da5 100644 --- a/tests/Resource/OfferingEligibilityTest.php +++ b/tests/Resource/OfferingEligibilityTest.php @@ -115,6 +115,30 @@ class OfferingEligibilityTest extends ResourceTestCase self::assertCount(2, $this->repo()->findEligible($address, $type)); } + public function testResourcesCoveringTheServiceCategoryComeFirst(): void + { + [, $clinic, $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + $category = new \App\ClinicService\Entity\CatalogCategory('clinic', (int) $clinic->getId(), 'پا'); + $this->em->persist($category); + $this->em->flush(); + + $service = $this->service($address, 'لیزر پا'); + $service->setCatalogCategory($category); + + // نامش عمداً الفبایی دوم است تا معلوم شود ترتیب از دسته می‌آید نه از نام. + $plain = $this->resource($address, $type, 'الف بدون دسته'); + $covering = $this->resource($address, $type, 'ی با دستهٔ پا'); + $covering->getCategories()->add($category); + $this->em->flush(); + + $eligible = $this->repo()->findEligible($address, $type, [], $service); + + self::assertCount(2, $eligible, 'تقدم است نه فیلتر — منبع بی‌دسته هم کاندید می‌ماند'); + self::assertSame('ی با دستهٔ پا', $eligible[0]->getName()); + } + public function testTheFilterStacksWithTheSkillFilter(): void { [, , $address] = $this->clinicWithAddress(); diff --git a/tests/Resource/ResourceBookingEndpointTest.php b/tests/Resource/ResourceBookingEndpointTest.php new file mode 100644 index 00000000..4e452d33 --- /dev/null +++ b/tests/Resource/ResourceBookingEndpointTest.php @@ -0,0 +1,146 @@ +clinicWithAddress(); + + $type = $this->resourceType($address); + $resource = new ClinicResource($address, $type, 'دستگاه لیزر ۲'); + $this->em->persist($resource); + + $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); + $this->em->persist($section); + $this->em->flush(); + + $service = new ServiceItem($section, 'لیزر پا', 8_000_000); + $service->setDurationMinutes(30)->setBookable(true); + $this->em->persist($service); + $this->em->flush(); + + return [$user, $resource, $service, $address]; + } + + // ── ✅ موفق ────────────────────────────────────────────────────────────── + + public function testTheServicesOfAResourceComeBackWithResolvedNumbers(): void + { + [$user, $resource, $service] = $this->clinicWithOfferedService(); + + $offering = new ResourceServiceOffering($resource, $service); + $offering->setDurationMinutes(15); // قیمت تنظیم نشده → باید ارث ببرد + $this->em->persist($offering); + $this->em->flush(); + + $body = $this->authJson('GET', "/api/v1/resource/{$resource->getUuid()}/services", $user); + + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $body['data']); + + $row = $body['data'][0]; + self::assertSame('لیزر پا', $row['service_name']); + self::assertSame(15, $row['effective_duration_minutes']); + self::assertSame('resource_option', $row['duration_source']); + // قیمت از خودِ سرویس آمده، و پنل باید همین را کنارش بنویسد. + self::assertSame(8_000_000, $row['effective_price_rials']); + self::assertSame('service_default', $row['price_source']); + } + + public function testReplacingTheListAddsAndRemoves(): void + { + [$user, $resource, $service] = $this->clinicWithOfferedService(); + + $created = $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, [ + 'services' => [ + ['service_uuid' => $service->getUuid(), 'duration_minutes' => 20, 'price_rials' => 9_500_000], + ], + ]); + + self::assertSame(200, $this->responseCode()); + self::assertSame(20, $created['data'][0]['duration_minutes']); + self::assertSame(9_500_000, (int) $created['data'][0]['price_rials']); + + // فهرست خالی یعنی «این منبع دیگر هیچ سرویسی نمی‌دهد» — جایگزینی کامل است. + $emptied = $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, ['services' => []]); + + self::assertSame(200, $this->responseCode()); + self::assertSame([], $emptied['data']); + } + + public function testClearingAnOverrideFallsBackToInheritance(): void + { + [$user, $resource, $service] = $this->clinicWithOfferedService(); + + $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, [ + 'services' => [['service_uuid' => $service->getUuid(), 'duration_minutes' => 20]], + ]); + + // رشتهٔ خالی یعنی «ارث»، نه صفر. + $body = $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, [ + 'services' => [['service_uuid' => $service->getUuid(), 'duration_minutes' => '']], + ]); + + self::assertNull($body['data'][0]['duration_minutes']); + self::assertSame(30, $body['data'][0]['effective_duration_minutes']); + self::assertSame('service_default', $body['data'][0]['duration_source']); + } + + // ── ❌ خطا ─────────────────────────────────────────────────────────────── + + public function testAServiceFromAnotherEnvironmentIsRejected(): void + { + [$user, $resource] = $this->clinicWithOfferedService(); + [, $otherClinic] = $this->clinicWithAddress('شعبهٔ کلینیک دیگر'); + + $section = new ServiceSection('clinic', (int) $otherClinic->getId(), 'بخش بیگانه'); + $this->em->persist($section); + $this->em->flush(); + $foreign = new ServiceItem($section, 'سرویس بیگانه', 1_000_000); + $this->em->persist($foreign); + $this->em->flush(); + + $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, [ + 'services' => [['service_uuid' => $foreign->getUuid()]], + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnUnknownServiceIsRejected(): void + { + [$user, $resource] = $this->clinicWithOfferedService(); + + $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, [ + 'services' => [['service_uuid' => '00000000-0000-4000-8000-000000000000']], + ]); + + self::assertSame(422, $this->responseCode()); + } + + public function testTheBodyMustCarryAServicesArray(): void + { + [$user, $resource] = $this->clinicWithOfferedService(); + + $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/services", $user, ['wrong' => []]); + + self::assertSame(422, $this->responseCode()); + } +}