my/clinic-doctors now reports has_schedule per doctor, and the appointments page builds tabs from it. A doctor with no working days had a tab that could only ever show an empty timeline. The flag is resolved with one query for the whole list rather than one per doctor. Clinic owners now read this authenticated endpoint too instead of the public clinic doctor-list, which is where the flag lives; admin keeps the public list and, with no flag present, hides nobody. Also repairs fallout from making the resource supervisor mandatory: four test classes build resources through their own helpers and were failing with 422. The supervisorFor helper moved to ApiTestCase so all domains share one, rather than copying it per suite. Full backend suite is green again (1306 tests) — the previous commit only ran tests/Resource and missed this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
402 lines
17 KiB
PHP
402 lines
17 KiB
PHP
<?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' => 'اتاق ۱',
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(),
|
||
]);
|
||
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' => 'اتاق ویزیت',
|
||
'supervisor_doctor_uuid' => $this->supervisorFor($c['address'])->getUuid(),
|
||
]);
|
||
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']);
|
||
}
|
||
}
|