Files
hamedandClaude Opus 5 fd59eb57a3 feat(appointments): only doctors with a weekly schedule get a tab
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>
2026-08-03 12:42:42 +03:30

402 lines
17 KiB
PHP
Raw Permalink 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' => 'اتاق ۱',
'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']);
}
}