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>
147 lines
6.2 KiB
PHP
147 lines
6.2 KiB
PHP
<?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());
|
|
}
|
|
}
|