Files
clinicpro/tests/ClinicService/ResourceServiceResolverTest.php
T
hamedandClaude Opus 5 4fe0c4f9bf refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:00:48 +03:30

199 lines
8.1 KiB
PHP

<?php
namespace App\Tests\ClinicService;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Service\ResourceServiceResolver;
use App\ClinicService\ValueObject\ResolvedServiceSpec;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* زنجیرهٔ حل مدت و قیمت: منبع+گزینه → منبع+سرویس → شعبه → پیش‌فرض آیتم.
*
* هر تست یک سطح را تنها می‌گذارد تا معلوم شود همان سطح برنده شده، نه سطحی که تصادفاً
* مقدار مشابه داشته.
*/
class ResourceServiceResolverTest extends ApiTestCase
{
private ResourceServiceResolver $resolver;
private DoctorAddress $address;
private ClinicResource $resource;
private ServiceItem $service;
private ServiceItem $option;
protected function setUp(): void
{
parent::setUp();
// resolver مستقیم ساخته می‌شود، نه از کانتینر: سرویس‌های اپ private اند و
// عمومی‌کردنشان فقط برای تست، پیکربندی را به‌خاطر تست خم می‌کند.
$this->resolver = new ResourceServiceResolver(
$this->em->getRepository(ResourceServiceOffering::class),
$this->em->getRepository(ServiceBranchOverride::class),
);
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک زنجیره');
$this->em->persist($clinic);
$this->em->flush();
$this->address = DoctorAddress::forClinic($clinic->getId());
$this->address->setName('شعبهٔ مرکزی');
$this->em->persist($this->address);
$type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه');
$this->em->persist($type);
$this->em->flush();
$this->resource = new ClinicResource($this->address, $type, 'لیزر دایود');
$this->em->persist($this->resource);
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
$this->em->persist($section);
$this->em->flush();
// سرویسِ والد «لیزر» و گزینهٔ «پا» — هر دو ServiceItem اند، همان مدل کاتالوگ.
$this->service = new ServiceItem($section, 'لیزر', 10_000_000);
$this->service->setDurationMinutes(60);
$this->em->persist($this->service);
$this->option = new ServiceItem($section, 'لیزر پا', 8_000_000);
$this->option->setDurationMinutes(30);
$this->em->persist($this->option);
$this->em->flush();
}
private function offer(ServiceItem $item, ?int $minutes, ?int $price, bool $active = true): ResourceServiceOffering
{
$offering = new ResourceServiceOffering($this->resource, $item);
$offering->setDurationMinutes($minutes)->setPriceRials($price)->setActive($active);
$this->em->persist($offering);
$this->em->flush();
return $offering;
}
private function branchOverride(ServiceItem $item, ?int $minutes): void
{
$override = new ServiceBranchOverride($item, $this->address);
$override->setSoloDurationMinutes($minutes);
$this->em->persist($override);
$this->em->flush();
}
private function resolve(): ResolvedServiceSpec
{
return $this->resolver->resolve($this->resource, $this->option, $this->address, $this->service);
}
// ── ✅ هر سطح، تنها ──────────────────────────────────────────────────────
public function testLevelOneResourcePlusOptionWins(): void
{
$this->offer($this->option, 15, 9_500_000);
$this->offer($this->service, 40, 12_000_000);
$this->branchOverride($this->option, 50);
$spec = $this->resolve();
self::assertSame(15, $spec->durationMinutes);
self::assertSame(9_500_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource);
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->priceSource);
}
public function testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing(): void
{
$this->offer($this->service, 40, 12_000_000);
$this->branchOverride($this->option, 50);
$spec = $this->resolve();
self::assertSame(40, $spec->durationMinutes);
self::assertSame(12_000_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_SERVICE, $spec->durationSource);
}
/** شعبه فقط مدت می‌دهد؛ قیمتش را از خودِ سرویس می‌گیرد. */
public function testLevelThreeBranchWinsForDurationOnly(): void
{
$this->branchOverride($this->option, 50);
$spec = $this->resolve();
self::assertSame(50, $spec->durationMinutes);
self::assertSame(ResolvedServiceSpec::SOURCE_BRANCH, $spec->durationSource);
self::assertSame(8_000_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource);
}
public function testLevelFourFallsBackToTheItemItself(): void
{
$spec = $this->resolve();
self::assertSame(30, $spec->durationMinutes);
self::assertSame(8_000_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->durationSource);
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource);
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testDurationAndPriceResolveIndependently(): void
{
// منبع فقط مدت را می‌گوید؛ قیمت باید تا خودِ سرویس پایین برود.
$this->offer($this->option, 15, null);
$this->branchOverride($this->option, null);
$spec = $this->resolve();
self::assertSame(15, $spec->durationMinutes);
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource);
self::assertSame(8_000_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->priceSource);
}
public function testAnInactiveOfferingIsSkippedEntirely(): void
{
// ردیف هست ولی خاموش: «این منبع فعلاً این را نمی‌دهد» یعنی اعدادش هم خوانده نمی‌شوند.
$this->offer($this->option, 15, 9_500_000, active: false);
$spec = $this->resolve();
self::assertSame(30, $spec->durationMinutes);
self::assertSame(8_000_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_SERVICE_DEFAULT, $spec->durationSource);
}
public function testAFreeServiceKeepsItsZeroInsteadOfInheriting(): void
{
// صفر مقدار واقعی است، نه «حرفی ندارم» — وگرنه سرویس رایگان قیمت سرویس والد را می‌گرفت.
$this->offer($this->option, null, 0);
$spec = $this->resolve();
self::assertSame(0, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->priceSource);
}
public function testResolvingTheServiceItselfNeedsNoParent(): void
{
$this->offer($this->service, 45, 13_000_000);
$spec = $this->resolver->resolve($this->resource, $this->service, $this->address);
self::assertSame(45, $spec->durationMinutes);
self::assertSame(13_000_000, $spec->priceRials);
self::assertSame(ResolvedServiceSpec::SOURCE_RESOURCE_OPTION, $spec->durationSource);
}
}