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()); + } +}