Files
clinicpro/tests/Pricing/PricingTest.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

400 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Tests\Pricing;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* زنجیرهٔ قیمت‌گذاری و فاکتور تفکیک‌شده — بند ۱۲ و قانون پنجم مستند.
*
* قیمت تنها یک منبع دارد: `ServiceItem::priceRials`. لیست قیمت، تعرفهٔ سالانه و قیمت
* اختصاصی شعبه حذف شده‌اند.
*/
class PricingTest extends ApiTestCase
{
/** @return array{user: User, section: ServiceSection, address: DoctorAddress} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک قیمت');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return ['user' => $user, 'section' => $section, 'address' => $address];
}
private function service(ServiceSection $section, string $name, int $price): ServiceItem
{
$section = $this->em->getRepository(ServiceSection::class)->find($section->getId());
$item = new ServiceItem($section, $name);
$item->setPriceRials($price);
$item->setSoloDurationMinutes(20);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
}
/** قیمت همیشه از خودِ سرویس می‌آید — هرگز صفر یا خطا. */
public function testFallsBackToTheServicePrice(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$body = $this->quote($c['user'], $service, $c['address']);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(5_000_000, $body['data']['base_rials']);
self::assertSame(5_000_000, $body['data']['final_rials']);
self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]);
}
/** override شعبه فقط مدت را عوض می‌کند؛ قیمت همچنان از خودِ سرویس می‌آید. */
public function testBranchOverrideNoLongerChangesThePrice(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [
'overrides' => [[
'address_uuid' => $c['address']->getUuid(),
'solo_duration_minutes' => 45,
]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->quote($c['user'], $service, $c['address']);
self::assertSame(5_000_000, $body['data']['base_rials']);
self::assertSame('service_item', $body['data']['breakdown']['sources'][$service->getUuid()]);
}
/** قیمتِ ارسالی برای override نادیده گرفته می‌شود — شعبه دیگر قیمت ندارد. */
public function testBranchOverridePayloadHasNoPriceField(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 3_000_000);
$body = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [
'overrides' => [[
'address_uuid' => $c['address']->getUuid(),
'price_rials' => 9_000_000,
]],
]);
self::assertSame(200, $this->responseCode());
self::assertArrayNotHasKey('price_rials', $body['data'][0]);
self::assertSame(3_000_000, $this->quote($c['user'], $service, $c['address'])['data']['base_rials']);
}
public function testFullChainAppliesInOrder(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 10_000_000);
$extra = $this->service($c['section'], 'ناحیهٔ اضافه', 2_000_000);
$body = $this->quote($c['user'], $service, $c['address'], [
'item_uuids' => [$extra->getUuid()],
'policy' => [
'discount_percent' => 10,
'insurance_base_percent' => 20,
'insurance_supplementary_percent' => 50,
'tax_percent' => 10,
'deposit_percent' => 30,
],
]);
$d = $body['data'];
self::assertSame(10_000_000, $d['base_rials']);
self::assertSame(2_000_000, $d['items_rials']);
self::assertSame(1_200_000, $d['discount_rials'], '۱۰٪ از ۱۲ میلیون');
self::assertSame(2_160_000, $d['insurance_base_rials'], '۲۰٪ از ۱۰٫۸ میلیون');
self::assertSame(4_320_000, $d['insurance_supplementary_rials'], '۵۰٪ از باقیمانده');
self::assertSame(432_000, $d['tax_rials'], '۱۰٪ روی سهم بیمار، نه روی کل');
self::assertSame(4_752_000, $d['final_rials']);
self::assertSame(1_425_600, $d['deposit_rials']);
}
/** تخفیف بیشتر از مبلغ، مبلغ را صفر می‌کند نه منفی. */
public function testDiscountLargerThanTheAmountFloorsAtZero(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 1_000_000);
$body = $this->quote($c['user'], $service, $c['address'], [
'policy' => ['discount_rials' => 5_000_000],
]);
self::assertSame(0, $body['data']['final_rials']);
self::assertGreaterThanOrEqual(0, $body['data']['discount_rials']);
}
/** سقف جمع تخفیف‌ها per محیط اعمال می‌شود. */
public function testTotalDiscountCapIsApplied(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 10_000_000);
$body = $this->quote($c['user'], $service, $c['address'], [
'policy' => [
'discount_percent' => 40,
'discount_rials' => 3_000_000,
'max_total_discount_percent' => 25,
],
]);
self::assertSame(2_500_000, $body['data']['discount_rials'], 'سقف ۲۵٪ از ۱۰ میلیون');
self::assertSame(7_500_000, $body['data']['final_rials']);
}
/**
* ⭐ جمعِ ردیف‌ها **باید** برابر مبلغ نهایی باشد.
*
* تست زنجیره عددها را تک‌تک می‌سنجد؛ این یکی خودِ رابطه را می‌سنجد. اگر روزی ردیف
* تازه‌ای اضافه شود و در جمع نهایی حساب نشود، آن تست سبز می‌ماند و این قرمز.
*/
public function testTheRowsAlwaysAddUpToTheFinalAmount(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 9_500_000);
$extra = $this->service($c['section'], 'ناحیهٔ اضافه', 3_300_000);
$d = $this->quote($c['user'], $service, $c['address'], [
'item_uuids' => [$extra->getUuid()],
'policy' => [
'discount_percent' => 7,
'insurance_base_percent' => 15,
'insurance_supplementary_percent' => 25,
'tax_percent' => 9,
],
])['data'];
$sum = $d['base_rials']
+ $d['items_rials']
- $d['discount_rials']
- $d['insurance_base_rials']
- $d['insurance_supplementary_rials']
+ $d['tax_rials'];
self::assertSame($d['final_rials'], $sum, json_encode($d, JSON_UNESCAPED_UNICODE));
}
/** بیعانهٔ مبلغی بر درصدی مقدم است و هرگز از مبلغ نهایی بیشتر نمی‌شود. */
public function testAFixedDepositOverridesThePercentageAndIsCapped(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 2_000_000);
$fixed = $this->quote($c['user'], $service, $c['address'], [
'policy' => ['deposit_percent' => 50, 'deposit_rials' => 300_000],
])['data'];
self::assertSame(300_000, $fixed['deposit_rials'], 'مبلغی برنده است، نه ۵۰٪');
$capped = $this->quote($c['user'], $service, $c['address'], [
'policy' => ['deposit_rials' => 9_000_000],
])['data'];
self::assertSame(
$capped['final_rials'],
$capped['deposit_rials'],
'بیعانهٔ بیشتر از مبلغ نهایی یعنی بدهکار کردن بیمار پیش از ویزیت',
);
}
public function testForeignServiceIsNotFound(): void
{
$c = $this->clinic();
$other = $this->clinic();
$foreign = $this->service($other['section'], 'سرویس بیگانه', 1_000_000);
$this->quote($c['user'], $foreign, $c['address']);
self::assertSame(404, $this->responseCode());
}
/**
* ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.»
*
* نوبت ثبت می‌شود، بعد قیمت سرویس دو برابر می‌شود، و فاکتور همان اعداد قبلی را
* می‌دهد. بدون این تست، تسک تأییدشده نیست.
*/
public function testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'لیزر', 4_000_000);
$room = new \App\Resource\Entity\ResourceType(
$c['address']->tenantEntityType(),
$c['address']->tenantEntityId(),
'room',
'اتاق',
);
$this->em->persist($room);
$this->em->flush();
$resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [
'address_uuid' => $c['address']->getUuid(),
'type_uuid' => $room->getUuid(),
'name' => 'اتاق ۱',
]);
self::assertSame(201, $this->responseCode());
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [
'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
]);
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [
'segments' => [[
'sequence' => 1, 'name' => 'لیزر', 'duration_minutes' => 20,
'requirements' => [['type_uuid' => $room->getUuid()]],
]],
]);
self::assertSame(200, $this->responseCode());
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاکتور');
$this->em->persist($doctor);
$this->em->flush();
$start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran')))
->setTime(9, 0)
->getTimestamp();
$hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $c['address']->getUuid(),
'start' => $start,
'assignment' => ['room' => [$resource['data']['uuid']]],
]);
self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE));
$confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $doctor->getUuid(),
'service_uuid' => $service->getUuid(),
'branch_uuid' => $c['address']->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE));
self::assertSame(4_000_000, $confirmed['data']['price_snapshot']['final_rials']);
$appointmentUuid = $confirmed['data']['appointment_uuid'];
// حالا قیمت دو برابر می‌شود.
$this->em->clear();
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $service->getUuid()]);
$reloaded->setPriceRials(8_000_000);
$this->em->flush();
// قیمت جدید در quote دیده می‌شود…
$fresh = $this->quote($c['user'], $reloaded, $c['address']);
self::assertSame(8_000_000, $fresh['data']['final_rials']);
// …ولی فاکتور نوبتِ ثبت‌شده دست‌نخورده است.
$snapshot = $this->authJson('GET', "/api/v1/appointment/$appointmentUuid/price-snapshot", $c['user']);
self::assertSame(200, $this->responseCode(), json_encode($snapshot, JSON_UNESCAPED_UNICODE));
self::assertSame(4_000_000, $snapshot['data']['final_rials'], 'قانون پنجم: فاکتور ثبت‌شده عوض نمی‌شود');
self::assertSame(4_000_000, $snapshot['data']['base_rials']);
}
/**
* ⭐ نوبت بدون سرویس هم فاکتور می‌گیرد.
*
* حالت اسلاتی سرویس ندارد؛ اگر فاکتورش ساخته نمی‌شد، گزارش مالی یک ردیف کم داشت و
* هیچ‌جا هم معلوم نمی‌شد کدام ردیف.
*/
public function testAnAppointmentWithoutAServiceStillGetsAnInvoice(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 1_500_000);
$room = new \App\Resource\Entity\ResourceType(
$c['address']->tenantEntityType(),
$c['address']->tenantEntityId(),
'room',
'اتاق',
);
$this->em->persist($room);
$this->em->flush();
$resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [
'address_uuid' => $c['address']->getUuid(),
'type_uuid' => $room->getUuid(),
'name' => 'اتاق ویزیت',
]);
self::assertSame(201, $this->responseCode());
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [
'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]),
]);
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [
'segments' => [[
'sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 15,
'requirements' => [['type_uuid' => $room->getUuid()]],
]],
]);
self::assertSame(200, $this->responseCode());
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر ویزیت');
$this->em->persist($doctor);
$this->em->flush();
$start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran')))
->setTime(11, 0)
->getTimestamp();
$hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $c['address']->getUuid(),
'start' => $start,
'assignment' => ['room' => [$resource['data']['uuid']]],
]);
self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE));
// ثبت **بدون** `service_uuid`: مسیر فاکتور تخت.
$confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [
'hold_uuid' => $hold['data']['hold_uuid'],
'doctor_uuid' => $doctor->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE));
$snapshot = $this->authJson(
'GET',
"/api/v1/appointment/{$confirmed['data']['appointment_uuid']}/price-snapshot",
$c['user'],
);
self::assertSame(200, $this->responseCode(), 'فاکتور باید وجود داشته باشد');
self::assertSame(0, $snapshot['data']['items_rials']);
self::assertSame($snapshot['data']['base_rials'], $snapshot['data']['final_rials']);
}
}