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>
199 lines
8.1 KiB
PHP
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);
|
|
}
|
|
}
|