Files
clinicpro/tests/Resource/OfferingEligibilityTest.php
T
hamedandClaude Opus 5 a6acf3bfe2 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>
2026-08-01 22:20:41 +03:30

167 lines
7.3 KiB
PHP

<?php
namespace App\Tests\Resource;
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;
use App\Resource\Repository\ClinicResourceRepository;
/**
* انتخاب کاندید بر اساس «کدام منبع این سرویس را می‌دهد».
*
* پیش از این، دو دستگاه هم‌نوع غیرقابل‌تفکیک بودند حتی وقتی فقط یکی‌شان آن سرویس را
* انجام می‌داد. فیلتر عمداً **مشروط** است: محیطی که هنوز رابطه‌ها را پر نکرده نباید
* یک‌شبه بی‌وقت شود.
*/
class OfferingEligibilityTest extends ResourceTestCase
{
private function service(DoctorAddress $address, string $name): ServiceItem
{
$section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name);
$this->em->persist($section);
$this->em->flush();
$item = new ServiceItem($section, $name, 5_000_000);
$item->setDurationMinutes(30);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function resource(DoctorAddress $address, ResourceType $type, string $name): ClinicResource
{
$resource = new ClinicResource($address, $type, $name);
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
private function repo(): ClinicResourceRepository
{
return $this->em->getRepository(ClinicResource::class);
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testOnlyTheResourceThatOffersTheServiceIsEligible(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$service = $this->service($address, 'لیزر CO2');
$offering = $this->resource($address, $type, 'دستگاه شمارهٔ ۱');
$this->resource($address, $type, 'دستگاه شمارهٔ ۲'); // این یکی وصل نیست
$this->em->persist(new ResourceServiceOffering($offering, $service));
$this->em->flush();
$eligible = $this->repo()->findEligible($address, $type, [], $service);
self::assertCount(1, $eligible);
self::assertSame('دستگاه شمارهٔ ۱', $eligible[0]->getName());
}
// ── ⚠️ مرزی: سازگاری عقب‌رو ──────────────────────────────────────────────
public function testWithoutAnyOfferingTheFilterIsNotAppliedAtAll(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$service = $this->service($address, 'سرویس بدون رابطه');
$this->resource($address, $type, 'دستگاه الف');
$this->resource($address, $type, 'دستگاه ب');
// هیچ ردیفی برای این سرویس نیست → همان رفتار قبلی، هر دو کاندیدند.
self::assertCount(2, $this->repo()->findEligible($address, $type, [], $service));
}
public function testDeactivatingEveryOfferingLeavesNoCandidate(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$service = $this->service($address, 'لیزر خاموش');
$first = $this->resource($address, $type, 'دستگاه الف');
$this->resource($address, $type, 'دستگاه ب');
$offering = new ResourceServiceOffering($first, $service);
$offering->setActive(false);
$this->em->persist($offering);
$this->em->flush();
// ردیف هست ولی خاموش: یعنی «کسی این را نمی‌دهد»، نه «فیلتری در کار نیست».
self::assertSame([], $this->repo()->findEligible($address, $type, [], $service));
}
public function testWithoutAServiceTheOldBehaviourIsUntouched(): void
{
[, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$service = $this->service($address, 'لیزر');
$only = $this->resource($address, $type, 'دستگاه تنها');
$this->resource($address, $type, 'دستگاه دیگر');
$this->em->persist(new ResourceServiceOffering($only, $service));
$this->em->flush();
// بدون سرویس، هیچ فیلتری اعمال نمی‌شود — مسیرهایی که سرویس ندارند نباید بشکنند.
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();
$type = $this->resourceType($address);
$service = $this->service($address, 'لیزر تخصصی');
$skilled = $this->resource($address, $type, 'دستگاه با مهارت');
$plain = $this->resource($address, $type, 'دستگاه بی‌مهارت');
$skill = new \App\Resource\Entity\Skill($address->tenantEntityType(), $address->tenantEntityId(), 'کار با لیزر');
$this->em->persist($skill);
$this->em->flush();
$this->em->persist(new \App\Resource\Entity\ResourceSkill($skilled, $skill, 3));
// هر دو سرویس را می‌دهند، ولی فقط یکی مهارتش را دارد.
$this->em->persist(new ResourceServiceOffering($skilled, $service));
$this->em->persist(new ResourceServiceOffering($plain, $service));
$this->em->flush();
$eligible = $this->repo()->findEligible($address, $type, [(int) $skill->getId()], $service);
self::assertCount(1, $eligible);
self::assertSame('دستگاه با مهارت', $eligible[0]->getName());
}
}