Book for a resource, and manage the services a resource offers

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 22:20:41 +03:30
co-authored by Claude Opus 5
parent 0a2ba88808
commit a6acf3bfe2
8 changed files with 608 additions and 11 deletions
@@ -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` |
@@ -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);
@@ -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
@@ -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();
}
@@ -0,0 +1,127 @@
<?php
namespace App\Resource\Service;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Service\ResourceServiceResolver;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Repository\ResourceServiceOfferingRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد — خواندن با مقادیرِ **حل‌شده** و جایگزینی کامل.
*
* جایگزینی کامل است نه افزودنی: تب پنل فهرست را همان‌طور که هست می‌فرستد، و ردیفی که
* در بدنه نیست یعنی کاربر برش داشته. همان قرارداد `PUT .../skills`.
*/
final class ResourceServiceAssignmentService
{
public function __construct(
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ServiceItemRepository $items,
private readonly ResourceServiceResolver $resolver,
private readonly EntityManagerInterface $em,
) {}
/**
* فهرست سرویس‌های منبع، با مقدارِ مؤثر و اینکه هر عدد از کجا آمده.
*
* پنل باید کنار خانهٔ خالی بنویسد «۳۰ دقیقه — پیش‌فرض سرویس»؛ بدون مقدار مؤثر،
* کاربر تفاوت «تنظیم نشده» و «صفر» را نمی‌فهمد.
*
* @return list<array<string, mixed>>
*/
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<array<string, mixed>> $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;
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* `POST /api/v1/appointment` با `resource_uuid`.
*
* مسیر قدیمی (`doctor_uuid`) باید دست‌نخورده بماند — سایت عمومی امروز همان را می‌فرستد
* و شکستنش در build هیچ کلاینتی خطا نمی‌دهد.
*/
class BookForResourceTest extends ApiTestCase
{
/** @return array{0: Clinic, 1: Doctor, 2: DoctorAddress, 3: ServiceSection} */
private function clinic(): array
{
$owner = $this->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<string, mixed> $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());
}
}
@@ -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();
@@ -0,0 +1,146 @@
<?php
namespace App\Tests\Resource;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
/**
* رزرو **برای یک منبع** و فهرست سرویس‌های آن منبع.
*
* تا پیش از این هر رزرو `doctor_uuid` می‌خواست، پس نوبتِ «دستگاه لیزر ۲» — که پزشکی
* ندارد — در مدل جایی نداشت.
*/
class ResourceBookingEndpointTest extends ResourceTestCase
{
/** @return array{0: User, 1: ClinicResource, 2: ServiceItem, 3: DoctorAddress} */
private function clinicWithOfferedService(): array
{
[$user, $clinic, $address] = $this->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());
}
}