Add the resource↔service link that decides who offers what, and for how much
Until now a resource was picked by type and skill alone, so two devices of the same type were indistinguishable even when only one of them performed the service — and there was nowhere to say that this doctor takes 30 minutes for a filler while that one takes 45. ResourceServiceOffering is that link: resource ↔ service item, with an optional duration, an optional price and an active flag. Because a "service option" here is itself a ServiceItem inside an ItemGroup, one table covers both levels the spec asks for — a row against the parent item is "resource + service", a row against a member item is "resource + option". A third table would have meant two sources of truth for one concept and a rewrite of every path that already speaks ServiceItem. It is an aggregate child of ClinicResource, like ResourceSkill: no tenant columns of its own, since the resource already carries the pair and a copy is just something that can drift. The constructor refuses a resource and a service from different environments — TenantFilter does not cover that case, as both uuids arrive from the request body and the filter does not apply to aggregate children. null means inherit, not zero: an explicit zero is a duration that does not exist, while null means this resource has nothing to say and the resolver should look one level up. Zero and negative values are rejected outright. Tests cover the pair being stored, the duplicate pair hitting the unique constraint, the cross-environment guard, null-means-inherit, one service across two devices with different numbers, and deactivating without losing them. Suite 1264 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
<?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 Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
|
||||
/**
|
||||
* «کدام منبع کدام سرویس را میدهد، با چه مدت و چه قیمتی.»
|
||||
*
|
||||
* تا پیش از این، انتخاب منبع فقط از نوع و مهارت میآمد؛ یعنی دو دستگاه همنوع
|
||||
* غیرقابلتفکیک بودند حتی وقتی فقط یکیشان آن سرویس را میداد.
|
||||
*/
|
||||
class ResourceServiceOfferingTest extends ResourceTestCase
|
||||
{
|
||||
private function service(DoctorAddress $address, string $name, int $price = 5_000_000): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$this->em->flush();
|
||||
|
||||
$item = new ServiceItem($section, $name, $price);
|
||||
$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;
|
||||
}
|
||||
|
||||
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAnOfferingCarriesTheDurationAndPriceOfThatResource(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
$resource = $this->resource($address, $type, 'لیزر دایود ۲');
|
||||
$service = $this->service($address, 'لیزر پا', 8_000_000);
|
||||
|
||||
$offering = new ResourceServiceOffering($resource, $service);
|
||||
$offering->setDurationMinutes(15)->setPriceRials(9_500_000);
|
||||
$this->em->persist($offering);
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
$stored = $this->em->getRepository(ResourceServiceOffering::class)->find($offering->getId());
|
||||
|
||||
self::assertSame(15, $stored->getDurationMinutes());
|
||||
self::assertSame(9_500_000, (int) $stored->getPriceRials());
|
||||
self::assertTrue($stored->isActive());
|
||||
// قیمت خودِ سرویس دستنخورده میماند؛ این ردیف رویش مینشیند، جایش را نمیگیرد.
|
||||
self::assertSame(8_000_000, $stored->getServiceItem()->getPriceRials());
|
||||
}
|
||||
|
||||
public function testNullMeansInheritNotZero(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
$resource = $this->resource($address, $type, 'لیزر بدون تنظیم');
|
||||
$service = $this->service($address, 'لیزر دست');
|
||||
|
||||
$offering = new ResourceServiceOffering($resource, $service);
|
||||
$this->em->persist($offering);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertNull($offering->getDurationMinutes());
|
||||
self::assertNull($offering->getPriceRials());
|
||||
|
||||
// صفر «مدتی که وجود ندارد» است، نه «حرفی برای گفتن ندارم» — پس رد میشود.
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$offering->setDurationMinutes(0);
|
||||
}
|
||||
|
||||
// ── ❌ خطا ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function testTheSamePairCannotBeRegisteredTwice(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
$resource = $this->resource($address, $type, 'لیزر تکراری');
|
||||
$service = $this->service($address, 'لیزر صورت');
|
||||
|
||||
$this->em->persist(new ResourceServiceOffering($resource, $service));
|
||||
$this->em->flush();
|
||||
|
||||
$this->em->persist(new ResourceServiceOffering($resource, $service));
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testAResourceCannotOfferAServiceFromAnotherEnvironment(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
[, , $otherAddress] = $this->clinicWithAddress('شعبهٔ کلینیک دیگر');
|
||||
|
||||
$resource = $this->resource($address, $this->resourceType($address), 'لیزر محیط الف');
|
||||
$foreign = $this->service($otherAddress, 'سرویس محیط ب');
|
||||
|
||||
// `TenantFilter` این را نمیگیرد: هر دو uuid از بدنهٔ درخواست میآیند و فیلتر
|
||||
// روی فرزند aggregate اعمال نمیشود. پس گارد باید در خودِ سازنده باشد.
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
new ResourceServiceOffering($resource, $foreign);
|
||||
}
|
||||
|
||||
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAServiceCanBeOfferedByMoreThanOneResourceWithDifferentNumbers(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
$service = $this->service($address, 'لیزر پا');
|
||||
|
||||
$first = $this->resource($address, $type, 'دستگاه شمارهٔ ۱');
|
||||
$second = $this->resource($address, $type, 'دستگاه شمارهٔ ۲');
|
||||
|
||||
$this->em->persist((new ResourceServiceOffering($first, $service))->setDurationMinutes(20)->setPriceRials(8_000_000));
|
||||
$this->em->persist((new ResourceServiceOffering($second, $service))->setDurationMinutes(15)->setPriceRials(9_500_000));
|
||||
$this->em->flush();
|
||||
|
||||
$repo = $this->em->getRepository(ResourceServiceOffering::class);
|
||||
|
||||
self::assertTrue($repo->hasAnyFor($service));
|
||||
self::assertCount(2, $repo->activeResourceIdsFor($service));
|
||||
|
||||
// همان مثال سند: یک سرویس، دو دستگاه، دو عدد متفاوت.
|
||||
self::assertSame(20, $repo->findOneFor($first, $service)?->getDurationMinutes());
|
||||
self::assertSame(15, $repo->findOneFor($second, $service)?->getDurationMinutes());
|
||||
}
|
||||
|
||||
public function testDeactivatingKeepsTheRowAndItsNumbers(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
$resource = $this->resource($address, $type, 'دستگاه خاموش');
|
||||
$service = $this->service($address, 'لیزر بیکینی');
|
||||
|
||||
$offering = new ResourceServiceOffering($resource, $service);
|
||||
$offering->setDurationMinutes(25)->setPriceRials(7_000_000)->setActive(false);
|
||||
$this->em->persist($offering);
|
||||
$this->em->flush();
|
||||
|
||||
$repo = $this->em->getRepository(ResourceServiceOffering::class);
|
||||
|
||||
// رابطه هست ولی فعال نیست: تنظیمات با خاموش/روشن کردن از دست نمیرود.
|
||||
self::assertTrue($repo->hasAnyFor($service));
|
||||
self::assertSame([], $repo->activeResourceIdsFor($service));
|
||||
self::assertSame(25, $repo->findOneFor($resource, $service)?->getDurationMinutes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user