Files
clinicpro/tests/Pricing/PricingTest.php
T
hamedandClaude Opus 5 2baa2ce7ca test: cover the paths that were reasoned about but never executed
Fifteen rows across five tasks said the mechanism was there and the test was
not. Each of these is a case where being wrong would be silent.

- the price rows must add up to the final amount. The chain test checks every
  number individually, which stays green if a new row is added and left out of
  the total; this checks the relationship itself.
- a fixed deposit beats a percentage one, and neither can exceed the final
  amount — charging a deposit larger than the bill puts the patient in debt
  before the visit.
- an appointment booked without a service still gets an invoice. Slot mode has
  no service, and without this the financial report is short a row with nothing
  to say which.
- the four accuracy thresholds, each tested on its own boundary. One step off
  and either everything is red (so nobody looks) or nothing is (so the report
  is pointless). Includes a short-running service, since the deviation is
  measured on its absolute value.
- all six policy templates build a policy that survives the normal validation,
  simulation and activation path. A template is a shortcut, not a second road:
  if one of them produced something the validator rejects, a user could create
  a rule in one click that never works.
- simulation leaves nothing pending for a later flush in the same request. That
  is what the finally-rollback-clear is for, and the failure would surface in
  the next operation rather than in the sandbox.

The course controller was reading $this->credits without it being injected —
phpstan caught it; the package-shortfall path had no test yet and would have
500'd on the first course that had a package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:16:26 +03:30

497 lines
22 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;
/**
* لیست قیمت بازه‌دار و فاکتور تفکیک‌شده — بند ۱۲ و قانون پنجم مستند.
*/
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(),
]);
}
private function priceList(User $user, string $name, int $from, int $to, ?string $addressUuid = null): array
{
$body = $this->authJson('POST', '/api/v1/price-lists', $user, array_filter([
'name' => $name,
'starts_at' => $from,
'ends_at' => $to,
'address_uuid' => $addressUuid,
]));
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
/** بدون هیچ لیست قیمتی، قیمت خودِ سرویس برمی‌گردد — هرگز صفر یا خطا. */
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()]);
}
/** لیست قیمت فقط در بازهٔ خودش حاکم است. */
public function testPriceListAppliesOnlyInsideItsRange(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+10 days');
$to = strtotime('+40 days');
$list = $this->priceList($c['user'], 'نیمهٔ دوم', $from, $to);
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 8_000_000]],
]);
self::assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode());
$inside = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(8_000_000, $inside['data']['base_rials'], 'داخل بازه: قیمت جدید');
$before = $this->quote($c['user'], $service, $c['address'], ['at' => $from - 86400]);
self::assertSame(5_000_000, $before['data']['base_rials'], 'پیش از بازه: قیمت قبلی');
}
/** دو لیست فعالِ هم‌پوشان یعنی یک تاریخ دو قیمت — هنگام فعال‌سازی رد می‌شود. */
public function testOverlappingActiveListsAreRejected(): void
{
$c = $this->clinic();
$from = strtotime('+10 days');
$first = $this->priceList($c['user'], 'اول', $from, $from + 30 * 86400);
$this->authJson('POST', "/api/v1/price-list/{$first['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode());
$second = $this->priceList($c['user'], 'دوم', $from + 10 * 86400, $from + 50 * 86400);
$body = $this->authJson('POST', "/api/v1/price-list/{$second['uuid']}/activate", $c['user']);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('هم‌پوشانی', $body['errors'][0]['message']);
}
/** لیستِ یک شعبه با لیست عمومی تداخل ندارد و بر آن مقدم است. */
public function testBranchListWinsOverTheGeneralList(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+10 days');
$to = $from + 30 * 86400;
$general = $this->priceList($c['user'], 'عمومی', $from, $to);
$this->authJson('PUT', "/api/v1/price-list/{$general['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]],
]);
$this->authJson('POST', "/api/v1/price-list/{$general['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode());
$branch = $this->priceList($c['user'], 'شعبهٔ مرکزی', $from, $to, $c['address']->getUuid());
$this->authJson('PUT', "/api/v1/price-list/{$branch['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_000_000]],
]);
$this->authJson('POST', "/api/v1/price-list/{$branch['uuid']}/activate", $c['user']);
self::assertSame(200, $this->responseCode(), 'لیست شعبه با لیست عمومی تداخل ندارد');
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(9_000_000, $body['data']['base_rials']);
}
/** override شعبه (تسک ۰۴) بر لیست قیمت مقدم است. */
public function testBranchOverrideBeatsThePriceList(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+10 days');
$list = $this->priceList($c['user'], 'عمومی', $from, $from + 30 * 86400);
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 7_000_000]],
]);
$this->authJson('POST', "/api/v1/price-list/{$list['uuid']}/activate", $c['user']);
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/branch-overrides", $c['user'], [
'overrides' => [['address_uuid' => $c['address']->getUuid(), 'price_rials' => 11_000_000]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(11_000_000, $body['data']['base_rials']);
self::assertSame('branch_override', $body['data']['breakdown']['sources'][$service->getUuid()]);
}
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 testNegativePriceIsRejected(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'ویزیت', 1_000_000);
$list = $this->priceList($c['user'], 'تست', strtotime('+1 day'), strtotime('+30 days'));
$body = $this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => -100]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('price_rials', $body['errors'][0]['field']);
}
/**
* ⭐ قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.»
*
* نوبت ثبت می‌شود، بعد قیمت سرویس دو برابر می‌شود، و فاکتور همان اعداد قبلی را
* می‌دهد. بدون این تست، تسک تأییدشده نیست.
*/
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']);
}
public function testDraftListHasNoEffectUntilActivated(): void
{
$c = $this->clinic();
$service = $this->service($c['section'], 'بوتاکس', 5_000_000);
$from = strtotime('+2 days');
$list = $this->priceList($c['user'], 'پیش‌نویس', $from, $from + 30 * 86400);
$this->authJson('PUT', "/api/v1/price-list/{$list['uuid']}/items", $c['user'], [
'items' => [['service_uuid' => $service->getUuid(), 'price_rials' => 9_999_999]],
]);
$body = $this->quote($c['user'], $service, $c['address'], ['at' => $from + 86400]);
self::assertSame(5_000_000, $body['data']['base_rials'], 'پیش‌نویس نباید قیمت را عوض کند');
}
}