Remove ReportTest and WaitlistTest files as part of codebase cleanup
This commit is contained in:
@@ -1,548 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Cancellation;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Settlement\Service\WalletService;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* سیاست لغو، جریمه و عدم حضور — تسک ۱۳.
|
||||
*
|
||||
* دو قاعده که شکستنشان گران است: لغو توسط کلینیک هرگز جریمه ندارد، و جریمه هرگز از
|
||||
* مبلغ پرداختی بیشتر نمیشود.
|
||||
*/
|
||||
class CancellationTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): 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);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر لغو');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$patientUser = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor, $patient];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes(30);
|
||||
$item->setPriceRials(4_000_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $body */
|
||||
private function savePolicy(User $user, array $body): array
|
||||
{
|
||||
$saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, $body);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $saved['data'];
|
||||
}
|
||||
|
||||
/** نوبتی در آینده، با مبلغ ثبتشده و در صورت نیاز پرداخت موفق. */
|
||||
private function appointment(
|
||||
Doctor $doctor,
|
||||
PatientRecord $patient,
|
||||
ServiceItem $service,
|
||||
int $clinicId,
|
||||
int $hoursAhead,
|
||||
int $price = 4_000_000,
|
||||
int $paid = 0,
|
||||
): Appointment {
|
||||
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
|
||||
$start = time() + $hoursAhead * 3600 + (++$this->slotCursor) * 60;
|
||||
|
||||
$appointment = new Appointment(
|
||||
$em->getRepository(Doctor::class)->find($doctor->getId()),
|
||||
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
|
||||
$start,
|
||||
$start + 1800,
|
||||
);
|
||||
$appointment->assignTenantPair('clinic', $clinicId);
|
||||
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
|
||||
$appointment->setVisitPriceRials($price);
|
||||
$appointment->setPatientName('بیمار لغو');
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
|
||||
if ($paid > 0) {
|
||||
$payment = new Payment($appointment->getUser(), $paid, 'zarinpal', Payment::TYPE_APPOINTMENT);
|
||||
$payment->assignTenantPair('clinic', $clinicId);
|
||||
$payment->setAppointment($appointment);
|
||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||
$em->persist($payment);
|
||||
$em->flush();
|
||||
}
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function wallet(): WalletService
|
||||
{
|
||||
return static::getContainer()->get(WalletService::class);
|
||||
}
|
||||
|
||||
// ── پیشنمایش ───────────────────────────────────────────────────────────
|
||||
|
||||
public function testInsideTheFreeWindowThereIsNoPenalty(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, [
|
||||
'free_window_hours' => 24,
|
||||
'penalty_mode' => 'percent',
|
||||
'penalty_value' => 50,
|
||||
'deposit_refundable' => false,
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 48, paid: 4_000_000);
|
||||
|
||||
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($preview, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(0, $preview['data']['penalty_rials']);
|
||||
self::assertTrue($preview['data']['deposit_refundable']);
|
||||
self::assertTrue($preview['data']['within_free_window']);
|
||||
}
|
||||
|
||||
public function testOutsideTheFreeWindowThePercentagePenaltyApplies(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, [
|
||||
'free_window_hours' => 24,
|
||||
'penalty_mode' => 'percent',
|
||||
'penalty_value' => 50,
|
||||
'deposit_refundable' => false,
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 6, paid: 4_000_000);
|
||||
|
||||
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
|
||||
|
||||
self::assertSame(2_000_000, $preview['penalty_rials']);
|
||||
self::assertFalse($preview['deposit_refundable']);
|
||||
self::assertFalse($preview['within_free_window']);
|
||||
}
|
||||
|
||||
/** ⭐ لغو توسط کلینیک هرگز جریمه ندارد، حتی یک ساعت مانده به نوبت. */
|
||||
public function testTheClinicCancellingItsOwnAppointmentIsAlwaysFree(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 100]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000);
|
||||
|
||||
$preview = $this->authJson(
|
||||
'GET',
|
||||
"/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=doctor",
|
||||
$user,
|
||||
)['data'];
|
||||
|
||||
self::assertSame(0, $preview['penalty_rials']);
|
||||
self::assertTrue($preview['deposit_refundable']);
|
||||
}
|
||||
|
||||
/** ⭐ جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ لغو نیست. */
|
||||
public function testThePenaltyNeverExceedsWhatWasPaid(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, [
|
||||
'free_window_hours' => 24,
|
||||
'penalty_mode' => 'fixed',
|
||||
'penalty_value' => 9_000_000,
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2, paid: 1_000_000);
|
||||
|
||||
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
|
||||
|
||||
self::assertSame(1_000_000, $preview['penalty_rials']);
|
||||
self::assertNotEmpty($preview['notes']);
|
||||
}
|
||||
|
||||
/** نوبت نقدی: جریمه صفر میشود و پاسخ توضیحش را میدهد. */
|
||||
public function testAnUnpaidAppointmentIsNotCharged(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2);
|
||||
|
||||
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
|
||||
|
||||
self::assertSame(0, $preview['penalty_rials']);
|
||||
self::assertStringContainsString('پرداختی نداشته', implode(' ', $preview['notes']));
|
||||
}
|
||||
|
||||
public function testWithoutAPolicyNothingIsCharged(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000);
|
||||
|
||||
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
|
||||
|
||||
self::assertSame(0, $preview['penalty_rials']);
|
||||
}
|
||||
|
||||
// ── لغو واقعی ───────────────────────────────────────────────────────────
|
||||
|
||||
public function testCancellingChargesTheWalletAndReleasesTheSlot(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 25]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000);
|
||||
|
||||
// کیف پول باید موجودی داشته باشد وگرنه جریمه کسر نمیشود.
|
||||
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$this->wallet()->charge($em->getRepository(\App\Auth\Entity\User::class)->find($appointment->getUser()->getId()), 5_000_000);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(1_000_000, $body['data']['penalty_rials']);
|
||||
self::assertTrue($body['data']['penalty_charged']);
|
||||
self::assertSame('cancelled_by_user', $body['data']['status']);
|
||||
|
||||
$patientUser = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
|
||||
->getRepository(\App\Auth\Entity\User::class)
|
||||
->find($appointment->getUser()->getId());
|
||||
|
||||
self::assertSame(4_000_000, $this->wallet()->balance($patientUser), 'جریمه باید از کیف پول کسر شود');
|
||||
}
|
||||
|
||||
/** موجودی ناکافی نباید لغو را شکست بدهد؛ نوبت باید آزاد شود. */
|
||||
public function testAnEmptyWalletDoesNotBlockTheCancellation(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(2_000_000, $body['data']['penalty_rials']);
|
||||
self::assertFalse($body['data']['penalty_charged'], 'موجودی نبود، پس کسر نشد — ولی نوبت لغو شد');
|
||||
self::assertSame('cancelled_by_user', $body['data']['status']);
|
||||
}
|
||||
|
||||
public function testCancellingTwiceIsRejected(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 30);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
/** برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. */
|
||||
public function testAPastAppointmentCannotBeCancelled(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5);
|
||||
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE appointments SET slot_start = ?, slot_end = ? WHERE uuid = ?',
|
||||
[time() - 7200, time() - 5400, $appointment->getUuid()],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── عدم حضور ────────────────────────────────────────────────────────────
|
||||
|
||||
/** ⭐ سومین عدم حضور برچسب پرریسک میگذارد — ولی بیمار را مسدود نمیکند. */
|
||||
public function testTheThirdNoShowTagsThePatient(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$tag = new TenantTag('clinic', (int) $address->getClinicId(), 'پرریسک', '#dc2626');
|
||||
$this->em->persist($tag);
|
||||
$this->em->flush();
|
||||
|
||||
$this->savePolicy($user, ['no_show_threshold' => 3, 'risk_tag_uuid' => $tag->getUuid()]);
|
||||
|
||||
$last = null;
|
||||
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2);
|
||||
$last = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($last, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
self::assertSame(3, $last['data']['count']);
|
||||
self::assertTrue($last['data']['tagged']);
|
||||
|
||||
$reloaded = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
|
||||
->getRepository(PatientRecord::class)
|
||||
->find($patient->getId());
|
||||
|
||||
$tagNames = array_map(static fn (TenantTag $t): string => $t->getName(), $reloaded->getTags()->toArray());
|
||||
|
||||
self::assertContains('پرریسک', $tagNames);
|
||||
}
|
||||
|
||||
/** ثبت دوباره روی همان نوبت، عدم حضور دوم نمیسازد. */
|
||||
public function testRecordingTheSameNoShowTwiceCountsOnce(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2);
|
||||
|
||||
$first = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
|
||||
$second = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
|
||||
|
||||
self::assertTrue($first['data']['recorded']);
|
||||
self::assertFalse($second['data']['recorded']);
|
||||
self::assertSame(1, $second['data']['count']);
|
||||
}
|
||||
|
||||
// ── جداسازی محیط ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ⭐ سیاست سرویس بر سیاست محیط مقدم است — بدون ترکیب.
|
||||
*
|
||||
* ترکیب («پنجرهٔ رایگانِ محیط با درصدِ سرویس») یعنی هیچکس نتواند بگوید عدد نهایی از
|
||||
* کجا آمد. سرویس اگر سیاست دارد، **همهاش** مال اوست.
|
||||
*/
|
||||
public function testTheServicePolicyWinsOverTheTenantPolicy(): void
|
||||
{
|
||||
[$user, $section, , $doctor, $patient] = $this->clinicWithPatient();
|
||||
$clinicId = (int) $patient->getEntityId();
|
||||
$service = $this->service($section);
|
||||
|
||||
// پنجرهٔ محیط یک ساعت است: با ۲۴ ساعت مانده، لغو رایگان میشد.
|
||||
$this->savePolicy($user, [
|
||||
'free_window_hours' => 1,
|
||||
'penalty_mode' => 'percent',
|
||||
'penalty_value' => 10,
|
||||
]);
|
||||
|
||||
// پنجرهٔ سرویس ۴۸ ساعت است: همان لغو، جریمه دارد.
|
||||
$saved = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/cancellation-policy", $user, [
|
||||
'free_window_hours' => 48,
|
||||
'penalty_mode' => 'percent',
|
||||
'penalty_value' => 50,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
// عددِ نهایی میگوید کدام سیاست حاکم بوده: ۰ یعنی محیط، ۵۰٪ یعنی سرویس.
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $clinicId, 24, 4_000_000, 4_000_000);
|
||||
|
||||
$preview = $this->authJson(
|
||||
'GET',
|
||||
"/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=user",
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(2_000_000, $preview['data']['penalty_rials'], 'سیاست سرویس حاکم است، نه سیاست محیط');
|
||||
self::assertFalse($preview['data']['within_free_window']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ برچسب پرریسک **مسدود نمیکند**.
|
||||
*
|
||||
* مسدودسازی یک قانون `eligibility` جداست؛ کلینیکی که میخواهد بیمار پرریسک را ببیند
|
||||
* ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند.
|
||||
*/
|
||||
public function testATaggedPatientCanStillBook(): void
|
||||
{
|
||||
[$user, $section, , $doctor, $patient] = $this->clinicWithPatient();
|
||||
$clinicId = (int) $patient->getEntityId();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->savePolicy($user, ['no_show_threshold' => 2]);
|
||||
|
||||
foreach ([1, 2, 3] as $i) {
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $clinicId, -$i * 24);
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
$summary = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user);
|
||||
|
||||
self::assertTrue($summary['data']['at_risk']);
|
||||
self::assertSame(3, $summary['data']['count']);
|
||||
|
||||
// و با همین وضعیت، نوبت تازه ثبت میشود.
|
||||
$fresh = $this->appointment($doctor, $patient, $service, $clinicId, 48);
|
||||
|
||||
self::assertSame(Appointment::STATUS_CONFIRMED, $fresh->getStatus());
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeTheNoShowSummary(): void
|
||||
{
|
||||
[$user, , , , $patient] = $this->clinicWithPatient();
|
||||
[$other] = $this->clinicWithPatient();
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ شکست اطلاعرسانی نباید لغو را برگرداند.
|
||||
*
|
||||
* حالا که همهٔ نوشتنها در یک تراکنشاند، این سؤال جدی است: اگر پیامک **داخل** آن
|
||||
* بلوک بود، یک خطای سرویس پیامک ظرفیت آزادشده را پس میگرفت و بیمار هم نوبت
|
||||
* نداشت هم وقتش را. اطلاعرسانی عمداً بعد از commit است و این تست همان را پین
|
||||
* میکند: با یک notifier که همیشه میترکد، لغو باز هم کامل انجام میشود.
|
||||
*/
|
||||
public function testAFailingNotifierDoesNotUndoTheCancellation(): void
|
||||
{
|
||||
[$user, $section, , $doctor, $patient] = $this->clinicWithPatient();
|
||||
$clinicId = (int) $patient->getEntityId();
|
||||
$service = $this->service($section);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $clinicId, 48, 4_000_000, 4_000_000);
|
||||
$uuid = $appointment->getUuid();
|
||||
|
||||
// برای اینکه اطلاعرسانی واقعاً به بیمار برسد، باید کسی در لیست انتظار باشد.
|
||||
$waiting = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('clinic', $clinicId, $waiting, 'clinic', $clinicId);
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$entry = new \App\Waitlist\Entity\WaitlistEntry(
|
||||
$record,
|
||||
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
|
||||
$appointment->getSlotStart() - 86400,
|
||||
$appointment->getSlotStart() + 86400,
|
||||
);
|
||||
$this->em->persist($entry);
|
||||
$this->em->flush();
|
||||
|
||||
// سرویس پیامکی که همیشه میترکد — همان چیزی که در تولید یک قطعی است.
|
||||
static::getContainer()->set(
|
||||
\App\Sms\Service\SmsService::class,
|
||||
new class extends \App\Sms\Service\SmsService {
|
||||
public function __construct() {}
|
||||
|
||||
public function dispatchAsync(
|
||||
string $mobile,
|
||||
string $message,
|
||||
string $provider = 'kavenegar',
|
||||
?string $templateUuid = null,
|
||||
array $templateVars = [],
|
||||
?string $templateCode = null,
|
||||
string $tag = \App\Sms\Entity\SmsLog::TAG_GLOBAL,
|
||||
): void {
|
||||
throw new \RuntimeException('sms provider down');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$threw = false;
|
||||
|
||||
try {
|
||||
static::getContainer()->get(\App\Cancellation\Service\CancellationService::class)
|
||||
->cancel($appointment, Appointment::STATUS_CANCELLED_BY_USER, $user);
|
||||
} catch (\RuntimeException) {
|
||||
$threw = true;
|
||||
}
|
||||
|
||||
self::assertTrue($threw, 'خطای اطلاعرسانی بالا میآید — پنهانش نمیکنیم');
|
||||
|
||||
// ولی خودِ لغو commit شده است.
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
self::assertSame(
|
||||
Appointment::STATUS_CANCELLED_BY_USER,
|
||||
$reloaded->getStatus(),
|
||||
'لغو نباید گروگان سرویس پیامک بماند',
|
||||
);
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotPreviewTheCancellation(): void
|
||||
{
|
||||
[$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
[$other] = $this->clinicWithPatient();
|
||||
|
||||
$service = $this->service($section);
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $other);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAPercentageAboveOneHundredIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithPatient();
|
||||
|
||||
$this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
|
||||
'penalty_mode' => 'percent',
|
||||
'penalty_value' => 150,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
namespace App\Tests\Course;
|
||||
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
#[Group('docs')]
|
||||
class CourseDocsCaptureTest extends ApiTestCase {
|
||||
public function testCapture(): void {
|
||||
if (getenv('COURSE_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: COURSE_DOCS=1'); }
|
||||
$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);
|
||||
$pu = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId());
|
||||
$this->em->persist($patient); $this->em->flush();
|
||||
$item = new ServiceItem($section, 'لیزر فولبادی'); $item->setSoloDurationMinutes(30); $item->setPriceRials(5000000);
|
||||
$this->em->persist($item); $this->em->flush();
|
||||
$d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); };
|
||||
$p = $this->authJson('POST','/api/v1/course-protocols',$user,[
|
||||
'service_uuid'=>$item->getUuid(),'session_count'=>8,'min_days'=>21,'ideal_days'=>28,'max_days'=>45,
|
||||
'steps'=>[['session_number'=>1,'params'=>['energy'=>12]],['session_number'=>2,'params'=>['energy'=>14]]],
|
||||
]);
|
||||
$d('PROTOCOL_CREATE', $p);
|
||||
$c = $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']]);
|
||||
$d('COURSE_CREATE', $c);
|
||||
$d('COURSE_SHOW', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}",$user));
|
||||
$d('NEXT_SLOT', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",$user));
|
||||
$d('PATIENT_COURSES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/courses",$user));
|
||||
$d('DUPLICATE', $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']]));
|
||||
$d('ABANDON', $this->authJson('POST',"/api/v1/treatment-course/{$c['data']['uuid']}/abandon",$user,['reason'=>'انصراف بیمار']));
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Course;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Course\Service\CourseSessionLinker;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دورهٔ درمان — تسک ۱۲.
|
||||
*
|
||||
* «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی میشناخت. تستها روی سه
|
||||
* چیز تمرکز دارند: snapshot پروتکل، لنگر متحرک فاصلهها، و اینکه لغو یک جلسه بقیهٔ
|
||||
* دوره را خراب نکند.
|
||||
*/
|
||||
class TreatmentCourseTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): 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);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر دوره');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$patientUser = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor, $patient];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name = 'لیزر فولبادی'): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes(30);
|
||||
$item->setPriceRials(5_000_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function protocol(User $user, ServiceItem $service, array $extra = []): array
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/course-protocols', $user, $extra + [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'session_count' => 8,
|
||||
'min_days' => 21,
|
||||
'ideal_days' => 28,
|
||||
'max_days' => 45,
|
||||
'steps' => [
|
||||
['session_number' => 1, 'params' => ['energy' => 12]],
|
||||
['session_number' => 2, 'params' => ['energy' => 14]],
|
||||
['session_number' => 3, 'params' => ['energy' => 16]],
|
||||
['session_number' => 4, 'params' => ['energy' => 18]],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
private function startCourse(User $user, PatientRecord $patient, string $protocolUuid): array
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/treatment-course', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'protocol_uuid' => $protocolUuid,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
private function courseEntity(string $uuid): TreatmentCourse
|
||||
{
|
||||
return static::getContainer()->get(TreatmentCourseRepository::class)->findByUuid($uuid);
|
||||
}
|
||||
|
||||
private function linker(): CourseSessionLinker
|
||||
{
|
||||
return static::getContainer()->get(CourseSessionLinker::class);
|
||||
}
|
||||
|
||||
// ── پروتکل ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testProtocolKeepsItsStepsAndSpacing(): void
|
||||
{
|
||||
[$user, $section] = $this->clinicWithPatient();
|
||||
$protocol = $this->protocol($user, $this->service($section));
|
||||
|
||||
self::assertSame(8, $protocol['session_count']);
|
||||
self::assertSame([21, 28, 45], [$protocol['min_days'], $protocol['ideal_days'], $protocol['max_days']]);
|
||||
self::assertSame([1, 2, 3, 4], array_column($protocol['steps'], 'session_number'));
|
||||
self::assertSame(16, $protocol['steps'][2]['params']['energy']);
|
||||
}
|
||||
|
||||
/** ترتیب فاصلهها معنا دارد؛ حداکثر کوچکتر از حداقل یعنی پروتکل غیرقابل اجرا. */
|
||||
public function testSpacingMustBeOrdered(): void
|
||||
{
|
||||
[$user, $section] = $this->clinicWithPatient();
|
||||
|
||||
$this->authJson('POST', '/api/v1/course-protocols', $user, [
|
||||
'service_uuid' => $this->service($section)->getUuid(),
|
||||
'session_count' => 6,
|
||||
'min_days' => 30,
|
||||
'ideal_days' => 20,
|
||||
'max_days' => 45,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** دورهٔ یکجلسهای همان نوبت تکی است. */
|
||||
public function testASingleSessionCourseIsRejected(): void
|
||||
{
|
||||
[$user, $section] = $this->clinicWithPatient();
|
||||
|
||||
$this->authJson('POST', '/api/v1/course-protocols', $user, [
|
||||
'service_uuid' => $this->service($section)->getUuid(),
|
||||
'session_count' => 1,
|
||||
'min_days' => 7,
|
||||
'ideal_days' => 7,
|
||||
'max_days' => 14,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOneProtocolPerService(): void
|
||||
{
|
||||
[$user, $section] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->protocol($user, $service);
|
||||
|
||||
$this->authJson('POST', '/api/v1/course-protocols', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'session_count' => 4,
|
||||
'min_days' => 7,
|
||||
'ideal_days' => 14,
|
||||
'max_days' => 21,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── شروع دوره ───────────────────────────────────────────────────────────
|
||||
|
||||
public function testStartingACourseCreatesEverySessionWithItsParams(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$protocol = $this->protocol($user, $this->service($section));
|
||||
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
self::assertCount(8, $course['sessions']);
|
||||
self::assertSame(array_fill(0, 8, 'planned'), array_column($course['sessions'], 'status'));
|
||||
self::assertSame(12, $course['sessions'][0]['params']['energy']);
|
||||
self::assertSame(18, $course['sessions'][3]['params']['energy']);
|
||||
|
||||
// جلسات ۵ تا ۸ پارامتری در پروتکل ندارند — آرایهٔ خالی، نه خطا.
|
||||
self::assertSame([], (array) $course['sessions'][7]['params']);
|
||||
|
||||
self::assertSame(
|
||||
['completed' => 0, 'booked' => 0, 'planned' => 8, 'skipped' => 0, 'total' => 8],
|
||||
array_intersect_key($course['progress'], array_flip(['completed', 'booked', 'planned', 'skipped', 'total'])),
|
||||
);
|
||||
self::assertSame(1, $course['progress']['next_session_number']);
|
||||
}
|
||||
|
||||
/** ⭐ تغییر پروتکل نباید دورهٔ در جریان را عوض کند. */
|
||||
public function testChangingTheProtocolLeavesRunningCoursesAlone(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$protocol = $this->protocol($user, $this->service($section));
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/course-protocol/{$protocol['uuid']}", $user, [
|
||||
'session_count' => 12,
|
||||
'min_days' => 30,
|
||||
'ideal_days' => 40,
|
||||
'max_days' => 60,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
|
||||
|
||||
self::assertSame(8, $after['session_count']);
|
||||
self::assertSame([21, 28, 45], [$after['min_days'], $after['ideal_days'], $after['max_days']]);
|
||||
self::assertCount(8, $after['sessions']);
|
||||
}
|
||||
|
||||
public function testASecondActiveCourseForTheSameServiceIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$protocol = $this->protocol($user, $this->service($section));
|
||||
|
||||
$first = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/treatment-course', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'protocol_uuid' => $protocol['uuid'],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
// پیام باید شناسهٔ دورهٔ موجود را بدهد تا اپراتور بتواند برود سراغش.
|
||||
self::assertStringContainsString($first['uuid'], $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
/** رهاکردن دوره جا را برای دورهٔ تازه باز میکند. */
|
||||
public function testAbandoningACourseFreesTheSlotForANewOne(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$protocol = $this->protocol($user, $this->service($section));
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, ['reason' => '']);
|
||||
self::assertSame(422, $this->responseCode(), 'رهاکردن بدون دلیل نباید پذیرفته شود');
|
||||
|
||||
$abandoned = $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, [
|
||||
'reason' => 'انصراف بیمار',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('abandoned', $abandoned['data']['status']);
|
||||
|
||||
$this->startCourse($user, $patient, $protocol['uuid']);
|
||||
}
|
||||
|
||||
// ── پیشرفت و لنگر متحرک ─────────────────────────────────────────────────
|
||||
|
||||
public function testProgressCountsCompletedSessionsAndPointsAtTheNextOne(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$protocol = $this->protocol($user, $service);
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3);
|
||||
|
||||
$after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
|
||||
|
||||
self::assertSame(3, $after['progress']['completed']);
|
||||
self::assertSame(8, $after['progress']['total']);
|
||||
self::assertSame(4, $after['progress']['next_session_number']);
|
||||
self::assertSame(18, $after['progress']['next_params']['energy']);
|
||||
}
|
||||
|
||||
/** ⭐ فاصله از آخرین جلسهٔ **انجامشده** حساب میشود، نه از شروع دوره. */
|
||||
public function testTheSuggestionAnchorsOnTheLastCompletedSession(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$protocol = $this->protocol($user, $service);
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$completedAt = time() - 10 * 86400;
|
||||
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3, $completedAt);
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
"/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$data = $body['data'];
|
||||
|
||||
self::assertSame(4, $data['session_number']);
|
||||
self::assertSame(18, $data['params']['energy']);
|
||||
self::assertSame($completedAt + 21 * 86400, $data['range']['min']);
|
||||
self::assertSame($completedAt + 45 * 86400, $data['range']['max']);
|
||||
self::assertSame($completedAt + 28 * 86400, $data['ideal_at']);
|
||||
self::assertNull($data['warning']);
|
||||
}
|
||||
|
||||
public function testPassingTheMaximumGapProducesAWarning(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$protocol = $this->protocol($user, $service);
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1, time() - 50 * 86400);
|
||||
|
||||
$data = $this->authJson(
|
||||
'GET',
|
||||
"/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",
|
||||
$user,
|
||||
)['data'];
|
||||
|
||||
self::assertNotNull($data['warning']);
|
||||
self::assertStringContainsString('45', $data['warning'], 'پیام باید حداکثر فاصلهٔ همان دوره را بگوید');
|
||||
}
|
||||
|
||||
// ── لغو یک جلسهٔ وسط دوره ───────────────────────────────────────────────
|
||||
|
||||
/** ⭐ لغو یک جلسه فقط همان جلسه را برمیگرداند؛ بقیهٔ دوره دستنخورده. */
|
||||
public function testCancellingOneSessionOnlyResetsThatSession(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$protocol = $this->protocol($user, $service);
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$entity = $this->courseEntity($course['uuid']);
|
||||
$sessions = $entity->getSessions()->toArray();
|
||||
usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
|
||||
$this->linker()->link($sessions[0], $appointment);
|
||||
|
||||
$second = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
|
||||
$this->linker()->link($this->reloadSession($sessions[1]->getUuid()), $second);
|
||||
|
||||
$before = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
|
||||
self::assertSame(['booked', 'booked'], array_slice(array_column($before['sessions'], 'status'), 0, 2));
|
||||
|
||||
self::assertTrue($this->linker()->unlink($this->reloadAppointment($appointment->getUuid())));
|
||||
|
||||
$after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
|
||||
|
||||
self::assertSame('planned', $after['sessions'][0]['status']);
|
||||
self::assertSame('booked', $after['sessions'][1]['status'], 'بقیهٔ جلسات نباید دست بخورند');
|
||||
}
|
||||
|
||||
public function testTheCourseCompletesOnlyWhenEverySessionIsDone(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
// پروتکل کوتاه تا کل دوره در تست تمام شود.
|
||||
$protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'session_count' => 2,
|
||||
'min_days' => 7,
|
||||
'ideal_days' => 14,
|
||||
'max_days' => 21,
|
||||
])['data'];
|
||||
|
||||
$course = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1);
|
||||
self::assertSame('active', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']);
|
||||
|
||||
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1);
|
||||
self::assertSame('completed', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']);
|
||||
}
|
||||
|
||||
// ── جداسازی محیط ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ⭐ «سختگیرانهتر برنده»: قانون `spacing` کلینیک با پروتکل دوره نمیجنگد.
|
||||
*
|
||||
* پروتکل ۷ روز میگوید و قانون ۲۱ روز؛ فاصلهٔ مؤثر باید ۲۱ باشد. اگر پروتکل برنده
|
||||
* میشد، قانونِ ایمنی کلینیک با تعریف یک پروتکل کوتاه دور زده میشد.
|
||||
*/
|
||||
public function testTheStricterOfProtocolAndSpacingPolicyWins(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$protocol = $this->protocol($user, $service, ['min_days' => 7, 'ideal_days' => 10, 'max_days' => 20]);
|
||||
$started = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$course = $this->courseEntity($started['uuid']);
|
||||
$scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class);
|
||||
|
||||
self::assertSame(7, $scheduler->effectiveMinDays($course), 'بدون قانون، پروتکل حاکم است');
|
||||
|
||||
$policy = new \App\Policy\Entity\Policy(
|
||||
$course->getEntityType(),
|
||||
$course->getEntityId(),
|
||||
\App\Policy\Entity\Policy::CATEGORY_SPACING,
|
||||
'حداقل ۲۱ روز بین جلسات لیزر',
|
||||
);
|
||||
$policy->setCondition(['match' => 'all', 'conditions' => []]);
|
||||
$policy->setEffects([['type' => 'min_days_between', 'value' => 21]]);
|
||||
$policy->setActive(true);
|
||||
|
||||
$this->em->persist($policy);
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
self::assertSame(
|
||||
21,
|
||||
$scheduler->effectiveMinDays($this->courseEntity($started['uuid'])),
|
||||
'قانون سختگیرتر برنده است',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ سقف افق: جلسهای که حتی حداقلِ فاصلهاش بیرون ۹۰ روز میافتد **رد** میشود، نه
|
||||
* اینکه `book-all` را بشکند. جلسات بیرون بازه `planned` میمانند تا بعداً رزرو شوند.
|
||||
*/
|
||||
public function testSessionsBeyondTheHorizonAreSkippedNotFailed(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
// فاصلهٔ ۶۰ روزه با ۸ جلسه: جلسهٔ سوم به بعد بیرون افق ۹۰ روزه است.
|
||||
$protocol = $this->protocol($user, $service, ['min_days' => 60, 'ideal_days' => 60, 'max_days' => 70]);
|
||||
$started = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$course = $this->courseEntity($started['uuid']);
|
||||
$scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class);
|
||||
|
||||
$now = time();
|
||||
$minDays = $scheduler->effectiveMinDays($course, $now);
|
||||
$horizon = $now + \App\Course\Service\CourseScheduler::SEARCH_HORIZON_DAYS * 86400;
|
||||
|
||||
// لنگر دوم = لنگر اول + ۶۰ روز؛ سومی از افق میگذرد.
|
||||
$third = $now + 3 * $minDays * 86400;
|
||||
|
||||
self::assertGreaterThan($horizon, $third, 'جلسهٔ سوم باید بیرون افق باشد');
|
||||
self::assertSame(60, $minDays);
|
||||
|
||||
// خودِ دوره دستنخورده میماند: هیچ جلسهای حذف نمیشود.
|
||||
self::assertCount(8, $course->getSessions()->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ `book-all` همه یا هیچ است.
|
||||
*
|
||||
* تقویم فقط یک روزِ هفته باز است و فاصلهٔ پروتکل ۱ تا ۲ روز؛ پس جلسهٔ اول وقت پیدا
|
||||
* میکند و جلسهٔ دوم نه. اگر تراکنش کار نکند، بیمار با یک نوبتِ تنها از یک دورهٔ
|
||||
* هشتجلسهای میماند و هیچکس نمیفهمد کجا قطع شد.
|
||||
*/
|
||||
public function testAFailedBookAllLeavesEverySessionPlanned(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'room',
|
||||
'name' => 'اتاق',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$resource = $this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => 'اتاق ۱',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// فقط شنبهها باز است.
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [
|
||||
'days' => [6 => [['start_minute' => 540, 'end_minute' => 1020]]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
|
||||
'segments' => [
|
||||
['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]],
|
||||
],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// بازهٔ ۱ تا ۲ روز: جلسهٔ دوم حتماً بیرون تنها روزِ باز میافتد.
|
||||
$protocol = $this->protocol($user, $service, ['min_days' => 1, 'ideal_days' => 1, 'max_days' => 2]);
|
||||
$started = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->em->clear();
|
||||
$course = $this->courseEntity($started['uuid']);
|
||||
|
||||
foreach ($course->getSessions() as $session) {
|
||||
self::assertSame(
|
||||
CourseSession::STATUS_PLANNED,
|
||||
$session->getStatus(),
|
||||
sprintf('جلسهٔ %d نباید رزرو مانده باشد', $session->getSessionNumber()),
|
||||
);
|
||||
self::assertNull($session->getAppointment());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ مسیر **موفق** `book-all`: لنگر بعد از هر رزرو جلو میرود.
|
||||
*
|
||||
* تست شکست از قبل بود؛ این یکی همان چیزی را میسنجد که کار میکند. لنگر ثابت یعنی
|
||||
* هر هشت جلسه دور همان تاریخ جمع میشوند و پروتکل عملاً بیاثر است.
|
||||
*/
|
||||
public function testBookAllMovesTheAnchorForwardBetweenSessions(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'room',
|
||||
'name' => 'اتاق',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$resource = $this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => 'اتاق دوره',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// هر روز باز — تا جلسات فقط با فاصلهٔ پروتکل جدا شوند، نه با تعطیلی.
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [
|
||||
'days' => array_fill_keys(range(0, 6), [['start_minute' => 480, 'end_minute' => 1200]]),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
|
||||
'segments' => [
|
||||
['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]],
|
||||
],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$protocol = $this->protocol($user, $service, [
|
||||
'session_count' => 3,
|
||||
'min_days' => 7,
|
||||
'ideal_days' => 7,
|
||||
'max_days' => 14,
|
||||
'steps' => [
|
||||
['session_number' => 1, 'params' => ['energy' => 12]],
|
||||
['session_number' => 2, 'params' => ['energy' => 14]],
|
||||
['session_number' => 3, 'params' => ['energy' => 16]],
|
||||
],
|
||||
]);
|
||||
$started = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(3, $body['data']['booked']);
|
||||
|
||||
$this->em->clear();
|
||||
$sessions = $this->courseEntity($started['uuid'])->getSessions()->toArray();
|
||||
|
||||
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$starts = array_map(
|
||||
static fn (CourseSession $s): ?int => $s->getAppointment()?->getSlotStart(),
|
||||
$sessions,
|
||||
);
|
||||
|
||||
self::assertNotContains(null, $starts, 'هر سه جلسه باید نوبت گرفته باشند');
|
||||
|
||||
// لنگر متحرک: هر جلسه دستکم هفت روز بعد از جلسهٔ قبلی است.
|
||||
for ($i = 1; $i < count($starts); $i++) {
|
||||
$gapDays = (int) floor(($starts[$i] - $starts[$i - 1]) / 86400);
|
||||
|
||||
self::assertGreaterThanOrEqual(7, $gapDays, sprintf(
|
||||
'فاصلهٔ جلسهٔ %d با قبلی %d روز شد',
|
||||
$i + 1,
|
||||
$gapDays,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeTheCourse(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
[$other] = $this->clinicWithPatient();
|
||||
|
||||
$protocol = $this->protocol($owner, $this->service($section));
|
||||
$course = $this->startCourse($owner, $patient, $protocol['uuid']);
|
||||
|
||||
$this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── کمکی ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function reloadSession(string $uuid): CourseSession
|
||||
{
|
||||
return static::getContainer()->get(CourseSessionRepository::class)->findByUuid($uuid);
|
||||
}
|
||||
|
||||
private function reloadAppointment(string $uuid): \App\Appointment\Entity\Appointment
|
||||
{
|
||||
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
|
||||
->getRepository(\App\Appointment\Entity\Appointment::class)
|
||||
->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): \App\Appointment\Entity\Appointment
|
||||
{
|
||||
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
|
||||
$start = time() + 86400 + (++$this->slotCursor) * 3600;
|
||||
|
||||
$appointment = new \App\Appointment\Entity\Appointment(
|
||||
$em->getRepository(Doctor::class)->find($doctor->getId()),
|
||||
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
|
||||
$start,
|
||||
$start + 1800,
|
||||
);
|
||||
$appointment->assignTenantPair('clinic', $clinicId);
|
||||
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
|
||||
$appointment->setPatientName('بیمار دوره');
|
||||
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
/** n جلسهٔ بعدی را رزرو و انجامشده میکند. */
|
||||
private function completeSessions(
|
||||
string $courseUuid,
|
||||
Doctor $doctor,
|
||||
PatientRecord $patient,
|
||||
ServiceItem $service,
|
||||
int $clinicId,
|
||||
int $count,
|
||||
?int $completedAt = null,
|
||||
): void {
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$course = $this->courseEntity($courseUuid);
|
||||
$sessions = $course->plannedSessions();
|
||||
|
||||
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $clinicId);
|
||||
|
||||
$this->linker()->link($this->reloadSession($sessions[0]->getUuid()), $appointment);
|
||||
$this->linker()->complete($this->reloadAppointment($appointment->getUuid()), $completedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
namespace App\Tests\Package;
|
||||
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
#[Group('docs')]
|
||||
class PackageDocsCaptureTest extends ApiTestCase {
|
||||
public function testCapture(): void {
|
||||
if (getenv('PKG_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: PKG_DOCS=1'); }
|
||||
$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);
|
||||
$pu = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId());
|
||||
$this->em->persist($patient); $this->em->flush();
|
||||
$item = new ServiceItem($section, 'لیزر فولبادی'); $item->setSoloDurationMinutes(20); $item->setPriceRials(5000000);
|
||||
$this->em->persist($item); $this->em->flush();
|
||||
$d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); };
|
||||
$c = $this->authJson('POST','/api/v1/packages',$user,['name'=>'۶ جلسه لیزر فولبادی','session_count'=>6,'price_rials'=>25000000,'validity_days'=>365,'service_uuids'=>[$item->getUuid()]]);
|
||||
$d('CREATE', $c);
|
||||
$d('INDEX', $this->authJson('GET','/api/v1/packages',$user));
|
||||
$s = $this->authJson('POST',"/api/v1/patient/{$patient->getUuid()}/package",$user,['package_uuid'=>$c['data']['uuid']]);
|
||||
$d('SELL', $s);
|
||||
$d('PATIENT_PACKAGES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/packages",$user));
|
||||
$this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1,'reason'=>'جبران جلسهٔ لغوشده']);
|
||||
$d('LEDGER', $this->authJson('GET',"/api/v1/patient-package/{$s['data']['uuid']}/ledger",$user));
|
||||
$d('ADJUST_NO_REASON', $this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1]));
|
||||
$d('QUOTE', $this->authJson('POST','/api/v1/pricing/quote',$user,['service_uuid'=>$item->getUuid(),'branch_uuid'=>$address->getUuid(),'patient_uuid'=>$patient->getUuid()]));
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Package;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Package\Entity\SessionCreditLedger;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* پکیج و دفتر اعتبار جلسات — تسک ۱۱.
|
||||
*
|
||||
* محور همهٔ تستها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه میداریم،
|
||||
* نه یک عدد شمارنده.» پس مانده هیچجا ذخیره نمیشود و هر تغییر یک ردیف است.
|
||||
*/
|
||||
class PackageLedgerTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): 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);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر پکیج');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$patientUser = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor, $patient];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name, int $price = 5_000_000): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes(20);
|
||||
$item->setPriceRials($price);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [
|
||||
'name' => '۶ جلسه لیزر فولبادی',
|
||||
'session_count' => $sessions,
|
||||
'price_rials' => 25_000_000,
|
||||
'service_uuids' => [$service->getUuid()],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
private function sell(User $user, PatientRecord $patient, string $packageUuid): array
|
||||
{
|
||||
$body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
|
||||
'package_uuid' => $packageUuid,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
/**
|
||||
* هر درخواست HTTP کرنل را ریبوت میکند و EntityManager تازه میشود، پس entity های
|
||||
* قبلی detached اند و باید دوباره خوانده شوند.
|
||||
*/
|
||||
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment
|
||||
{
|
||||
// یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد.
|
||||
$start = time() + 86400 + (++$this->slotCursor) * 3600;
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
$patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId());
|
||||
$service = $this->em->getRepository(ServiceItem::class)->find($service->getId());
|
||||
|
||||
$appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200);
|
||||
$appointment->assignTenantPair('clinic', $clinicId);
|
||||
$appointment->setServiceItem($service);
|
||||
$appointment->setPatientName('بیمار پکیج');
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function ledgerService(): CreditLedgerService
|
||||
{
|
||||
return static::getContainer()->get(CreditLedgerService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهای کانتینر با EntityManager خودشان کار میکنند؛ entity ساختهشده در تست
|
||||
* باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» میشود.
|
||||
*/
|
||||
private function reload(Appointment $appointment): Appointment
|
||||
{
|
||||
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
|
||||
->getRepository(Appointment::class)
|
||||
->find($appointment->getId());
|
||||
}
|
||||
|
||||
private function consumption(): \App\Package\Service\PackageConsumptionService
|
||||
{
|
||||
return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class);
|
||||
}
|
||||
|
||||
// ── تعریف و فروش ────────────────────────────────────────────────────────
|
||||
|
||||
public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر فولبادی');
|
||||
|
||||
$package = $this->definePackage($user, $service);
|
||||
$sold = $this->sell($user, $patient, $package['uuid']);
|
||||
|
||||
self::assertSame(6, $sold['balance']);
|
||||
|
||||
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
|
||||
self::assertSame(6, $list['data'][0]['balance']);
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
|
||||
self::assertCount(1, $ledger['data']['rows']);
|
||||
self::assertSame('purchase', $ledger['data']['rows'][0]['kind']);
|
||||
self::assertSame(6, $ledger['data']['rows'][0]['delta']);
|
||||
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
|
||||
}
|
||||
|
||||
/** پکیجی که هیچ سرویسی را پوشش نمیدهد هرگز قابل مصرف نیست. */
|
||||
public function testAPackageWithoutServicesIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithPatient();
|
||||
|
||||
$this->authJson('POST', '/api/v1/packages', $user, [
|
||||
'name' => 'پکیج بیسرویس',
|
||||
'session_count' => 3,
|
||||
'price_rials' => 1_000_000,
|
||||
'service_uuids' => [],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینهسازی» شش ماه بعد را میگیرد. */
|
||||
public function testNoStoredBalanceColumnExists(): void
|
||||
{
|
||||
$columns = $this->em->getConnection()
|
||||
->createSchemaManager()
|
||||
->listTableColumns('patient_packages');
|
||||
|
||||
$names = array_map(static fn ($c): string => strtolower($c->getName()), $columns);
|
||||
|
||||
foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
|
||||
self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره');
|
||||
}
|
||||
}
|
||||
|
||||
// ── مصرف و بازگشت ───────────────────────────────────────────────────────
|
||||
|
||||
public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$package = $this->definePackage($user, $service);
|
||||
$sold = $this->sell($user, $patient, $package['uuid']);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
|
||||
|
||||
$appointment = $this->reload($appointment);
|
||||
self::assertTrue($this->consumption()->consumeFor($appointment));
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(5, $this->ledgerService()->balance($entity));
|
||||
|
||||
self::assertTrue($this->ledgerService()->refund($appointment));
|
||||
self::assertSame(6, $this->ledgerService()->balance($entity));
|
||||
|
||||
// ردیف `consume` **حذف نمیشود** — دفتر append-only است.
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$kinds = array_column($ledger['data']['rows'], 'kind');
|
||||
|
||||
self::assertSame(['purchase', 'consume', 'refund'], $kinds);
|
||||
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ `credit_refundable: false` اعتبار برگشته را پس میگیرد — **بدون** حذف ردیف.
|
||||
*
|
||||
* دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن
|
||||
* `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد.
|
||||
*/
|
||||
public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
// نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان
|
||||
// نیست بلکه درونِ محدودهٔ جریمه میافتد — تنها حالتی که سیاست اعتبار اثر دارد.
|
||||
$saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
|
||||
'free_window_hours' => 96,
|
||||
'penalty_mode' => 'none',
|
||||
'credit_refundable' => false,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
$appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
|
||||
self::assertTrue($this->consumption()->consumeFor($appointment));
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertFalse($body['data']['credit_refundable']);
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$kinds = array_column($ledger['data']['rows'], 'kind');
|
||||
|
||||
self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمیشود');
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد');
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ رقابت واقعی: ردیف `consume` از یک اتصال دیگر درج میشود و بعد سرویس تلاش
|
||||
* میکند همان را بنویسد.
|
||||
*
|
||||
* بررسی پیش از درج این پنجره را نمیبندد؛ فقط کلید یکتا میبندد. و چون Doctrine روی
|
||||
* نقض کلید `EntityManager` را میبندد، بدون بازنشانیِ رجیستری این حالت به یک ۵۰۰
|
||||
* بیربط تبدیل میشد — نه یک «قبلاً مصرف شده».
|
||||
*/
|
||||
public function testAConcurrentConsumeIsAbsorbedWithoutBurningTheRequest(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
$appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
|
||||
$package = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
|
||||
// اتصال جدا = «درخواست دیگر». ردیف مصرف را پشت سرِ سرویس درج میکند.
|
||||
$other = \Doctrine\DBAL\DriverManager::getConnection($this->em->getConnection()->getParams());
|
||||
|
||||
try {
|
||||
$other->insert('session_credit_ledger', [
|
||||
'patient_package_id' => $package->getId(),
|
||||
'appointment_id' => $appointment->getId(),
|
||||
'kind' => 'consume',
|
||||
'delta' => -1,
|
||||
'created_at' => time(),
|
||||
'entity_type' => $package->getEntityType(),
|
||||
'entity_id' => $package->getEntityId(),
|
||||
'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(),
|
||||
]);
|
||||
} finally {
|
||||
$other->close();
|
||||
}
|
||||
|
||||
// سرویس همان مصرف را دوباره تلاش میکند: باید `true` بدهد، نه خطا.
|
||||
self::assertTrue($this->consumption()->consumeFor($this->reload($appointment)));
|
||||
|
||||
// و مهمتر: مدیر هنوز زنده است و کارِ بعدی همین request انجام میشود.
|
||||
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
self::assertTrue($em->isOpen(), 'EntityManager نباید بعد از نقض کلید بسته بماند');
|
||||
|
||||
$fresh = $em->getRepository(\App\Package\Entity\PatientPackage::class)->findOneBy(['uuid' => $sold['uuid']]);
|
||||
self::assertSame(5, $this->ledgerService()->balance($fresh), 'فقط یک جلسه خورده شود');
|
||||
}
|
||||
|
||||
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
|
||||
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
|
||||
|
||||
$appointment = $this->reload($appointment);
|
||||
$this->consumption()->consumeFor($appointment);
|
||||
$this->consumption()->consumeFor($appointment);
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
|
||||
self::assertSame(5, $this->ledgerService()->balance($entity));
|
||||
}
|
||||
|
||||
/** ماندهٔ صفر خطا نیست: بیمار نقدی میپردازد. */
|
||||
public function testAnEmptyPackageIsSimplyNotApplied(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']);
|
||||
|
||||
$first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
$second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
|
||||
|
||||
self::assertTrue($this->consumption()->consumeFor($first));
|
||||
self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بیسروصدا رد شود');
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمیشود');
|
||||
}
|
||||
|
||||
// ── قیمت ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void
|
||||
{
|
||||
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر', 5_000_000);
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
|
||||
self::assertTrue($quote['data']['package_will_be_consumed']);
|
||||
self::assertSame(0, $quote['data']['final_rials']);
|
||||
|
||||
// پیشنمایش هرگز مصرف نمیکند؛ وگرنه هر رفرش یک جلسه میخورد.
|
||||
$this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
]);
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
self::assertSame(6, $this->ledgerService()->balance($entity));
|
||||
}
|
||||
|
||||
public function testQuoteWithoutAPatientChargesTheFullPrice(): void
|
||||
{
|
||||
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر', 5_000_000);
|
||||
|
||||
$this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertFalse($quote['data']['package_will_be_consumed']);
|
||||
self::assertSame(5_000_000, $quote['data']['final_rials']);
|
||||
}
|
||||
|
||||
// ── FIFO و انقضا ────────────────────────────────────────────────────────
|
||||
|
||||
/** قدیمیترین اول، چون به انقضا نزدیکتر است. */
|
||||
public function testTheOldestUnexpiredPackageIsUsedFirst(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$definition = $this->definePackage($user, $service);
|
||||
$older = $this->sell($user, $patient, $definition['uuid']);
|
||||
$newer = $this->sell($user, $patient, $definition['uuid']);
|
||||
|
||||
$repo = static::getContainer()->get(PatientPackageRepository::class);
|
||||
$olderE = $repo->findByUuid($older['uuid']);
|
||||
|
||||
// خرید دوم را عمداً تازهتر میکنیم تا ترتیب قطعی باشد.
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?',
|
||||
[$newer['uuid']],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$chosen = $this->consumption()->firstUsable(
|
||||
$this->em->getRepository(PatientRecord::class)->find($patient->getId()),
|
||||
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
|
||||
);
|
||||
|
||||
self::assertSame($olderE->getUuid(), $chosen?->getUuid());
|
||||
}
|
||||
|
||||
public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
|
||||
[time() - 86400, $sold['uuid']],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
|
||||
|
||||
self::assertTrue($list['data'][0]['expired']);
|
||||
self::assertSame(0, $list['data'][0]['balance']);
|
||||
|
||||
// دفتر دستنخورده است: «۶ جلسهام چه شد؟» هنوز جواب دارد.
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
|
||||
}
|
||||
|
||||
public function testExpiryCommandWritesTheClosingRow(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
|
||||
[time() - 86400, $sold['uuid']],
|
||||
);
|
||||
$this->em->clear();
|
||||
|
||||
$command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class);
|
||||
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
|
||||
$tester->execute([]);
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$kinds = array_column($ledger['data']['rows'], 'kind');
|
||||
|
||||
self::assertSame(['purchase', 'expiry'], $kinds);
|
||||
self::assertSame(-6, $ledger['data']['rows'][1]['delta']);
|
||||
self::assertSame(0, $ledger['data']['rows'][1]['running_balance']);
|
||||
}
|
||||
|
||||
// ── اصلاح دستی و جداسازی محیط ───────────────────────────────────────────
|
||||
|
||||
public function testAdjustmentNeedsAReasonAndIsRecorded(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]);
|
||||
self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود');
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
|
||||
'delta' => 2,
|
||||
'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
|
||||
$row = $ledger['data']['rows'][1];
|
||||
|
||||
self::assertSame('adjustment', $row['kind']);
|
||||
self::assertSame(2, $row['delta']);
|
||||
self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']);
|
||||
self::assertNotNull($row['created_by']);
|
||||
self::assertSame(8, $row['running_balance']);
|
||||
}
|
||||
|
||||
public function testAdjustmentCannotDriveTheBalanceNegative(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
|
||||
'delta' => -10,
|
||||
'reason' => 'اشتباه اپراتور',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeOrTouchThePackage(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
[$other] = $this->clinicWithPatient();
|
||||
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [
|
||||
'delta' => 5,
|
||||
'reason' => 'تلاش از محیط دیگر',
|
||||
]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** منشی نباید بتواند اعتبار را دستی عوض کند. */
|
||||
public function testASecretaryCannotAdjustTheLedger(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
|
||||
|
||||
$secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [
|
||||
'delta' => 5,
|
||||
'reason' => 'تلاش منشی',
|
||||
]);
|
||||
|
||||
self::assertContains($this->responseCode(), [403, 404]);
|
||||
}
|
||||
|
||||
public function testLedgerRowsNeverHaveAZeroDelta(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
|
||||
'delta' => 0,
|
||||
'reason' => 'بیاثر',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
|
||||
|
||||
self::expectException(\InvalidArgumentException::class);
|
||||
new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
|
||||
/**
|
||||
* خروجی واقعیِ اندپوینتهای قانون برای `docs/api/policy.md`.
|
||||
*
|
||||
* جدا از تستهای رفتاری است و در گروه `docs` میماند تا در اجرای عادی نویز نسازد.
|
||||
*/
|
||||
#[Group('docs')]
|
||||
class DocsCaptureTest extends ApiTestCase
|
||||
{
|
||||
public function testCapture(): void
|
||||
{
|
||||
// بهصورت پیشفرض رد میشود: کارش تولید خروجی برای مستندات است، نه ادعای رفتار.
|
||||
if (getenv('POLICY_DOCS') !== '1') {
|
||||
self::markTestSkipped('برای تولید خروجی مستندات: POLICY_DOCS=1');
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر صورت');
|
||||
$service->setSoloDurationMinutes(20);
|
||||
$service->setPriceRials(1_000_000);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
$dump = function (string $label, mixed $body): void {
|
||||
fwrite(
|
||||
STDERR,
|
||||
sprintf("\n===%s %d===\n%s\n", $label, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)),
|
||||
);
|
||||
};
|
||||
|
||||
$dump('SCHEMA', $this->authJson('GET', '/api/v1/policy-schema', $user));
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل یک ساعت برای لیزر',
|
||||
'priority' => 10,
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 1],
|
||||
]],
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
|
||||
]);
|
||||
$dump('CREATE', $created);
|
||||
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$dump('ACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/activate", $user));
|
||||
|
||||
$dump('VERSION', $this->authJson('POST', "/api/v1/policy/$uuid/version", $user, [
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
|
||||
]));
|
||||
|
||||
$dump('SHOW', $this->authJson('GET', "/api/v1/policy/$uuid", $user));
|
||||
$dump('INDEX', $this->authJson('GET', '/api/v1/policies?category=timing', $user));
|
||||
|
||||
$dump('BAD_FIELD', $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون نامعتبر',
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10],
|
||||
]],
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]));
|
||||
|
||||
$dump('BAD_EFFECT', $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'اثر نامعتبر',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]));
|
||||
|
||||
$dump('DEACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/deactivate", $user));
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
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 NoPolicyRegressionTest extends ApiTestCase
|
||||
{
|
||||
public function testWithoutAnyPolicyThePlanAndQuoteAreUnchanged(): void
|
||||
{
|
||||
$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();
|
||||
|
||||
$service = new ServiceItem($section, 'لیزر');
|
||||
$service->setSoloDurationMinutes(20);
|
||||
$service->setPriceRials(1_000_000);
|
||||
$this->em->persist($service);
|
||||
$this->em->flush();
|
||||
|
||||
$plan = $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
self::assertSame([0], array_column($plan['data']['segments'], 'offset_minutes'));
|
||||
|
||||
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(1_000_000, $quote['data']['base_rials']);
|
||||
self::assertSame(0, $quote['data']['discount_rials']);
|
||||
self::assertSame(1_000_000, $quote['data']['final_rials']);
|
||||
|
||||
// نبودِ کلید مهمتر از صفر بودن مقدار است: کلیدِ خالی هم یعنی موتور چیزی
|
||||
// اعمال کرده که نباید میکرد.
|
||||
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
|
||||
|
||||
$selection = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
|
||||
'item_uuids' => [$service->getUuid()],
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertTrue($selection['data']['valid']);
|
||||
self::assertSame([], $selection['data']['errors']);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Policy\Service\OperatorRegistry;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* یازده عملگر، هر کدام روی هر دو نتیجه.
|
||||
*
|
||||
* عملگری که غلط بسنجد قانونی میسازد که یا همیشه میگیرد یا هرگز — و هیچکدام خطا
|
||||
* نمیدهند. تست واحد است نه یکپارچه، چون خودِ مقایسه تابعی خالص است.
|
||||
*/
|
||||
class OperatorRegistryTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_800_000_000;
|
||||
|
||||
#[DataProvider('cases')]
|
||||
public function testOperatorDecides(string $op, mixed $actual, mixed $expected, bool $result): void
|
||||
{
|
||||
self::assertSame(
|
||||
$result,
|
||||
(new OperatorRegistry())->evaluate($op, $actual, $expected, self::NOW),
|
||||
sprintf('%s(%s, %s)', $op, json_encode($actual), json_encode($expected)),
|
||||
);
|
||||
}
|
||||
|
||||
public static function cases(): array
|
||||
{
|
||||
$day = 86400;
|
||||
|
||||
return [
|
||||
'equals' => ['equals', 5, 5, true],
|
||||
'equals — رشتهٔ عددی' => ['equals', '5', 5, true],
|
||||
'equals — نه' => ['equals', 5, 6, false],
|
||||
'not_equals' => ['not_equals', 5, 6, true],
|
||||
'greater_than' => ['greater_than', 6, 5, true],
|
||||
'greater_than — مرز' => ['greater_than', 5, 5, false],
|
||||
'greater_or_equal' => ['greater_or_equal', 5, 5, true],
|
||||
'less_than' => ['less_than', 4, 5, true],
|
||||
'less_or_equal' => ['less_or_equal', 5, 5, true],
|
||||
'in' => ['in', 2, [1, 2, 3], true],
|
||||
'in — نه' => ['in', 9, [1, 2, 3], false],
|
||||
'not_in' => ['not_in', 9, [1, 2, 3], true],
|
||||
'between — داخل' => ['between', 30, [18, 65], true],
|
||||
'between — مرز پایین' => ['between', 18, [18, 65], true],
|
||||
'between — مرز بالا' => ['between', 65, [18, 65], true],
|
||||
'between — بیرون' => ['between', 66, [18, 65], false],
|
||||
'contains' => ['contains', ['vip', 'new'], 'vip', true],
|
||||
'contains — نه' => ['contains', ['new'], 'vip', false],
|
||||
'days_since — گذشته' => ['days_since', self::NOW - 40 * $day, 30, true],
|
||||
'days_since — تازه' => ['days_since', self::NOW - 10 * $day, 30, false],
|
||||
'days_since — هرگز' => ['days_since', 0, 30, false],
|
||||
];
|
||||
}
|
||||
|
||||
/** عملگر ناشناخته `false` میدهد، نه خطا — ولی ذخیرهاش از قبل جلوگیری شده. */
|
||||
public function testAnUnknownOperatorIsFalseAndNotRegistered(): void
|
||||
{
|
||||
$registry = new OperatorRegistry();
|
||||
|
||||
self::assertFalse($registry->has('regex'));
|
||||
self::assertFalse($registry->evaluate('regex', 'a', 'a'));
|
||||
}
|
||||
|
||||
/** فرم فقط عملگرهای معنادار همان نوع را نشان میدهد. */
|
||||
public function testOperatorsAreFilteredByFieldType(): void
|
||||
{
|
||||
$registry = new OperatorRegistry();
|
||||
|
||||
self::assertSame(['contains'], $registry->forType('list'));
|
||||
self::assertSame(['equals'], $registry->forType('bool'));
|
||||
self::assertContains('days_since', $registry->forType('timestamp'));
|
||||
self::assertNotContains('between', $registry->forType('uuid'));
|
||||
}
|
||||
}
|
||||
@@ -1,617 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* موتور قوانین ششدستهای — بند ۸ مستند.
|
||||
*
|
||||
* تأکید تستها روی سه چیز است که خرابیشان بیصداست: ترکیب اثرها (max/sum/veto)،
|
||||
* ترتیب حل تناقض (اولویت ← اختصاصیبودن ← قدمت)، و نسخهپذیری (قانون ویرایش
|
||||
* نمیشود، نسخهٔ تازه میگیرد).
|
||||
*/
|
||||
class PolicyEngineTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
|
||||
private function clinicWithBranch(): 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, $section, $address];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name, int $solo = 20, int $price = 1_000_000): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes($solo);
|
||||
$item->setPriceRials($price);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* قانون تازه **پیشنویس** است؛ تا فعال نشود اجرا نمیشود.
|
||||
*
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function policy(User $user, array $body, bool $activate = true): array
|
||||
{
|
||||
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (!$activate) {
|
||||
return $created['data'];
|
||||
}
|
||||
|
||||
// فعالسازی از تسک ۱۰ به بعد یک اجرای آزمایشی از **همین نسخه** میخواهد.
|
||||
$this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/simulate", $user);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user);
|
||||
self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $active['data'];
|
||||
}
|
||||
|
||||
/** پیشنویس ماندنِ قانون تازه عمدی است: نوشتن قانون نباید یعنی اجرای آن. */
|
||||
public function testANewPolicyIsADraftUntilActivated(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'خدمت پیشنویس', 20);
|
||||
|
||||
$draft = $this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون پیشنویس',
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
|
||||
], activate: false);
|
||||
|
||||
self::assertFalse($draft['active']);
|
||||
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
|
||||
{
|
||||
return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @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 testSchemaIsAClosedListPerCategory(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$body = $this->authJson('GET', '/api/v1/policy-schema', $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$schema = $body['data'];
|
||||
|
||||
self::assertArrayHasKey('timing', $schema);
|
||||
self::assertContains('equals', array_column($schema['timing']['operators'], 'value'));
|
||||
|
||||
self::assertSame(
|
||||
['min_duration_minutes', 'add_duration_minutes'],
|
||||
array_column($schema['timing']['effects'], 'type'),
|
||||
);
|
||||
self::assertSame(
|
||||
['max', 'sum'],
|
||||
array_column($schema['timing']['effects'], 'combination'),
|
||||
);
|
||||
|
||||
// فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بستهبودن نکتهٔ اصلی شِماست.
|
||||
self::assertNotContains('subtotal_rials', $schema['timing']['fields']);
|
||||
|
||||
// فرم باید عملگرها را per فیلد فیلتر کند، وگرنه کاربر «برچسب > ۵» میسازد و
|
||||
// ۴۲۲ میگیرد بیآنکه بفهمد چرا.
|
||||
$meta = array_column($schema['eligibility']['field_meta'], null, 'key');
|
||||
|
||||
self::assertSame('int', $meta['patient_age']['type']);
|
||||
// عدد یازده عملگر ندارد؛ فقط آنهایی که روی عدد معنا دارند.
|
||||
self::assertSame(
|
||||
['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal', 'between', 'in', 'not_in'],
|
||||
$meta['patient_age']['operators'],
|
||||
);
|
||||
self::assertSame(['contains'], $meta['patient_tags']['operators']);
|
||||
self::assertSame('سن بیمار', $meta['patient_age']['label']);
|
||||
}
|
||||
|
||||
public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون بیربط',
|
||||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10]]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function testEffectOutsideTheCategoryIsRejectedAtCreateTime(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'تخفیف در دستهٔ زمان',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── ترکیب اثرها ─────────────────────────────────────────────────────────
|
||||
|
||||
/** «حداقل مدت» با max ترکیب میشود: سختگیرترین قانون برنده است. */
|
||||
public function testMinDurationTakesTheLargestNotTheLast(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل ۴۵ دقیقه',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||||
]);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل ۶۰ دقیقه',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(60, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** «افزودن مدت» با sum ترکیب میشود — دو قانون ۱۰ دقیقهای یعنی ۲۰ دقیقه. */
|
||||
public function testAddDurationSumsAcrossPolicies(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'پاکسازی', 20);
|
||||
|
||||
foreach (['ضدعفونی اضافه', 'آمادهسازی اضافه'] as $name) {
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => $name,
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 10]],
|
||||
]);
|
||||
}
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(40, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** یک ممنوعیت کافی است؛ ممنوعیت رأی اکثریت نیست. */
|
||||
public function testOneForbidVetoesTheSelection(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'بوتاکس', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'selection',
|
||||
'name' => 'این خدمت فعلاً ارائه نمیشود',
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت موقتاً متوقف است']],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
|
||||
'item_uuids' => [$service->getUuid()],
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertFalse($body['data']['valid']);
|
||||
self::assertSame('policy_forbidden', $body['data']['errors'][0]['code']);
|
||||
self::assertSame('این خدمت موقتاً متوقف است', $body['data']['errors'][0]['message']);
|
||||
}
|
||||
|
||||
// ── شرطها ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testConditionThatDoesNotMatchLeavesThePlanAlone(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'مشاوره', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'فقط برای انتخابهای پرتعداد',
|
||||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]]],
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
/** حقیقتِ غایب یعنی شرط **برقرار نیست** — نه اینکه بیصدا رد شود. */
|
||||
public function testMissingFactFailsTheClauseInsteadOfPassingIt(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر بدن', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'وابسته به سن',
|
||||
'condition' => ['match' => 'all', 'conditions' => [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]]],
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
|
||||
]);
|
||||
|
||||
// پیشنمایش برنامه سن بیمار را نمیفرستد.
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
public function testExpiredPolicyIsIgnored(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'میکرونیدلینگ', 20);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'کمپین نوروز',
|
||||
'valid_from' => time() - 86400 * 30,
|
||||
'valid_to' => time() - 86400,
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 25]],
|
||||
]);
|
||||
|
||||
$plan = $this->preview($user, $service, $address);
|
||||
|
||||
self::assertSame(20, $plan['data']['total_minutes']);
|
||||
}
|
||||
|
||||
public function testDeactivatedPolicyIsIgnored(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'هیدرافیشیال', 20);
|
||||
|
||||
$policy = $this->policy($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون خاموششدنی',
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 20]],
|
||||
]);
|
||||
|
||||
self::assertSame(40, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/deactivate", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
|
||||
}
|
||||
|
||||
// ── ترتیب و اختصاصیبودن ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* در تساوی اولویت، قانونِ اختصاصیتر اول مینشیند — همان که برچسبش روی فاکتور
|
||||
* میرود.
|
||||
*/
|
||||
public function testMoreSpecificPolicyIsRankedFirstOnEqualPriority(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'فیلر', 20, 2_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف عمومی محیط',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||||
]);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف همین سرویس',
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
$quote = $this->quote($user, $service, $address);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$applied = $quote['data']['breakdown']['sources']['applied_policies'];
|
||||
|
||||
self::assertSame('تخفیف همین سرویس', $applied[0]['name']);
|
||||
// درصدها جمع میشوند: ۵٪ + ۱۰٪ روی ۲٬۰۰۰٬۰۰۰
|
||||
self::assertSame(300_000, $quote['data']['discount_rials']);
|
||||
}
|
||||
|
||||
public function testHigherPriorityBeatsSpecificity(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'مزوتراپی', 20, 1_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'قانون محیطی با اولویت بالا',
|
||||
'priority' => 100,
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||||
]);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'قانون سرویسی با اولویت پایین',
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'priority' => 1,
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 5]],
|
||||
]);
|
||||
|
||||
$applied = $this->quote($user, $service, $address)['data']['breakdown']['sources']['applied_policies'];
|
||||
|
||||
self::assertSame('قانون محیطی با اولویت بالا', $applied[0]['name']);
|
||||
}
|
||||
|
||||
// ── نسخه ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* قانون **ویرایش نمیشود**: تغییر یعنی نسخهٔ تازه، و شمارهٔ نسخه در فاکتور ثبت
|
||||
* میشود تا سه ماه بعد بشود گفت کدام متن اعمال شده بود (قانون پنجم مستند).
|
||||
*/
|
||||
public function testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر صورت', 20, 1_000_000);
|
||||
|
||||
$policy = $this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف پاییز',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
self::assertSame(1, $policy['version']);
|
||||
|
||||
$first = $this->quote($user, $service, $address);
|
||||
self::assertSame(100_000, $first['data']['discount_rials']);
|
||||
self::assertSame(1, $first['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||||
|
||||
$updated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 20]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(2, $updated['data']['version']);
|
||||
|
||||
// نسخهٔ تازه فعال میماند؛ آزمایش دوباره لازم نیست چون قانون از قبل فعال بود.
|
||||
|
||||
$second = $this->quote($user, $service, $address);
|
||||
self::assertSame(200_000, $second['data']['discount_rials']);
|
||||
self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']);
|
||||
|
||||
// هر دو نسخه در تاریخچه میمانند.
|
||||
$show = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}", $user);
|
||||
self::assertSame([1, 2], array_column($show['data']['versions'], 'version'));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ نسخهٔ تازه نمیتواند ادعا کند از دیروز برقرار بوده.
|
||||
*
|
||||
* نوبتهای دیروز با متن قبلی حساب شدهاند؛ اعتبار عقبرونده یعنی ردپای قیمتها با
|
||||
* قانونی توضیح داده شود که آن روز وجود نداشت.
|
||||
*/
|
||||
public function testANewVersionCannotStartInThePast(): void
|
||||
{
|
||||
[$user, , ] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف پاییز',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
], false);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'valid_from' => time() - 7 * 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
// آینده مجاز است.
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'valid_from' => time() + 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ شش عملگر، هر کدام جدا. عملگری که غلط بسنجد، قانونی میسازد که یا همیشه
|
||||
* میگیرد یا هرگز — و هیچکدام خطا نمیدهند.
|
||||
*
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('operatorCases')]
|
||||
public function testEachOperatorDecidesOnItsOwn(array $clause, bool $expected): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'خدمت عملگر', 20, 1_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'آزمون عملگر',
|
||||
'condition' => ['match' => 'all', 'conditions' => [$clause]],
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 50]],
|
||||
]);
|
||||
|
||||
$quote = $this->quote($user, $service, $address);
|
||||
|
||||
self::assertSame(
|
||||
$expected ? 500_000 : 0,
|
||||
$quote['data']['discount_rials'],
|
||||
json_encode($clause, JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
}
|
||||
|
||||
/** بدون `item_uuids` هیچ آیتم اضافهای انتخاب نشده، پس `item_count` صفر است. */
|
||||
public static function operatorCases(): array
|
||||
{
|
||||
return [
|
||||
'equals میگیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 0], true],
|
||||
'equals نمیگیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 9], false],
|
||||
'not_equals میگیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 9], true],
|
||||
'not_equals نمیگیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 0], false],
|
||||
'greater_than میگیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => -1], true],
|
||||
'greater_than نمیگیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 5], false],
|
||||
'less_than میگیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 5], true],
|
||||
'less_than نمیگیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 0], false],
|
||||
'in میگیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [0, 2]], true],
|
||||
'in نمیگیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [7, 8]], false],
|
||||
'contains نمیگیرد' => [['field' => 'patient_tags', 'operator' => 'contains', 'value' => 'vip'], false],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ قانون `spacing` در لحظهٔ **رزرو موقت** اجرا میشود، نه هنگام تولید کاندید.
|
||||
*
|
||||
* هزینهاش یک اسلات است که نمایش داده میشود و بعد رد میشود؛ سودش این است که
|
||||
* جستجوی وقت بهازای هر کاندید یک کوئری تاریخچهٔ بیمار نمیزند. این تست همان مرز را
|
||||
* پین میکند: نوبت نزدیک رد میشود، نوبت دور میگذرد.
|
||||
*/
|
||||
public function testSpacingRejectsABookingTooCloseToTheLastOne(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20, 1_000_000);
|
||||
|
||||
$this->policy($user, [
|
||||
'category' => 'spacing',
|
||||
'name' => 'حداقل ۲۱ روز بین جلسات',
|
||||
'effects' => [['type' => 'min_days_between', 'value' => 21]],
|
||||
]);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاصله');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$last = time() - 5 * 86400;
|
||||
|
||||
$previous = new \App\Appointment\Entity\Appointment($doctor, $patient, $last, $last + 1200);
|
||||
$previous->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
|
||||
$previous->setServiceItem($this->em->getRepository(ServiceItem::class)->find($service->getId()));
|
||||
$previous->setAddressId($address->getId());
|
||||
$previous->setPatientName('بیمار فاصله');
|
||||
$previous->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($previous);
|
||||
$this->em->flush();
|
||||
|
||||
$guard = static::getContainer()->get(\App\Policy\Service\BookingPolicyGuard::class);
|
||||
|
||||
// پنج روز بعد از جلسهٔ قبلی → رد.
|
||||
$rejected = false;
|
||||
try {
|
||||
$guard->assertSpacing($patient, $service, $address, $last + 5 * 86400);
|
||||
} catch (\App\Shared\Exception\AppException $e) {
|
||||
$rejected = true;
|
||||
self::assertStringContainsString('۲۱', str_replace(
|
||||
['0','1','2','3','4','5','6','7','8','9'],
|
||||
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'],
|
||||
$e->getMessage(),
|
||||
));
|
||||
}
|
||||
self::assertTrue($rejected, 'فاصلهٔ کمتر از قانون باید رد شود');
|
||||
|
||||
// سی روز بعد → میگذرد.
|
||||
$guard->assertSpacing($patient, $service, $address, $last + 30 * 86400);
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ `specificity` هنگام **ذخیره** حساب میشود و در تساوی اولویت تصمیم میگیرد.
|
||||
*
|
||||
* محاسبهاش در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود، در هر رزرو
|
||||
* تکرار شود؛ و ذخیرهشدنش یعنی میشود روزی مرتبسازی را به SQL برد.
|
||||
*/
|
||||
public function testSpecificityIsStoredAndDecidesTiesAtEqualPriority(): void
|
||||
{
|
||||
[$user, $section, $address] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر', 20, 1_000_000);
|
||||
|
||||
// قانون عام: بدون دامنه، بدون شرط.
|
||||
$broad = $this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف عمومی',
|
||||
'priority' => 5,
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
// قانون خاص: همان اولویت، ولی سرویس و یک شرط دارد.
|
||||
$narrow = $this->policy($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف همین سرویس',
|
||||
'priority' => 5,
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 0],
|
||||
]],
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 40]],
|
||||
]);
|
||||
|
||||
self::assertSame(0, $broad['specificity'], 'قانون بیدامنه و بیشرط');
|
||||
self::assertSame(5, $narrow['specificity'], 'سرویس ۴ + یک شرط ۱');
|
||||
|
||||
// هر دو اعمال میشوند (تخفیف درصدی جمع میشود)، ولی **ترتیب** مال specificity است:
|
||||
// اختصاصیتر اول میآید، و همان ترتیبی است که اثرهای «اولی برنده» را تعیین میکند.
|
||||
$quote = $this->quote($user, $service, $address);
|
||||
$names = array_column($quote['data']['breakdown']['sources']['applied_policies'], 'name');
|
||||
|
||||
self::assertSame(['تخفیف همین سرویس', 'تخفیف عمومی'], $names, 'اختصاصیتر باید اول باشد');
|
||||
}
|
||||
|
||||
// ── جداسازی محیط ────────────────────────────────────────────────────────
|
||||
|
||||
public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void
|
||||
{
|
||||
[$owner, , ] = $this->clinicWithBranch();
|
||||
[$other, $section, $address] = $this->clinicWithBranch();
|
||||
|
||||
$service = $this->service($section, 'خدمت کلینیک دوم', 20, 1_000_000);
|
||||
|
||||
$foreign = $this->policy($owner, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف کلینیک اول',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 50]],
|
||||
]);
|
||||
|
||||
$this->authJson('GET', "/api/v1/policy/{$foreign['uuid']}", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$quote = $this->quote($other, $service, $address);
|
||||
|
||||
self::assertSame(0, $quote['data']['discount_rials']);
|
||||
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\FieldRegistry;
|
||||
use App\Policy\Service\OperatorRegistry;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* هر فیلدی که schema تبلیغ میکند، باید جایی در کد **واقعاً پر شود**.
|
||||
*
|
||||
* خطر مشخص است و بیصداست: قانونی که روی فیلدی شرط بگذارد که هیچ فراخوانی آن را
|
||||
* نمیفرستد، هرگز مطابقت نمیکند و هیچ خطایی هم نمیدهد. اپراتور قانون را میسازد،
|
||||
* فعالش میکند، و تا ابد فکر میکند دارد کار میکند.
|
||||
*
|
||||
* این تست عمداً ساختاری است نه رفتاری: پیمایشِ همهٔ مسیرهای واقعی برای هر فیلد،
|
||||
* دستگاه تستی میخواست بزرگتر از خودِ موتور.
|
||||
*/
|
||||
class PolicyFieldCoverageTest extends TestCase
|
||||
{
|
||||
public function testEveryAdvertisedFieldIsSuppliedSomewhereInTheCode(): void
|
||||
{
|
||||
$registry = new FieldRegistry(new OperatorRegistry());
|
||||
$fields = [];
|
||||
|
||||
foreach (Policy::CATEGORIES as $category) {
|
||||
foreach ($registry->forCategory($category) as $field) {
|
||||
$fields[$field][] = $category;
|
||||
}
|
||||
}
|
||||
|
||||
$sources = $this->sourceFiles(dirname(__DIR__, 2) . '/src');
|
||||
$missing = [];
|
||||
|
||||
foreach ($fields as $field => $categories) {
|
||||
$found = false;
|
||||
|
||||
foreach ($sources as $file => $code) {
|
||||
// خودِ schema فقط نام را اعلام میکند؛ پر کردنش جای دیگری است.
|
||||
if (str_ends_with($file, 'PolicySchema.php') || str_ends_with($file, 'FieldRegistry.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($code, sprintf("'%s'", $field)) && str_contains($code, '=>')) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
$missing[$field] = $categories;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
[],
|
||||
$missing,
|
||||
'این فیلدها در schema هستند ولی هیچجا در context پر نمیشوند: ' .
|
||||
json_encode($missing, JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, string> مسیر => محتوا */
|
||||
private function sourceFiles(string $root): array
|
||||
{
|
||||
$files = [];
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||||
$files[$file->getPathname()] = (string) file_get_contents($file->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Policy;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* آزمایشگاه قانون — تسک ۱۰.
|
||||
*
|
||||
* مهمترین تستِ این فایل `testSimulationWritesNothingButItsOwnRun` است: هر بار که کسی
|
||||
* `PolicySimulator` را عوض کند، همان تست جلوی نوشتنِ ناخواسته را میگیرد.
|
||||
*/
|
||||
class PolicySimulationTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
|
||||
private function clinicWithBranch(): 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);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر آزمون');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name, int $price = 1_000_000): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes(20);
|
||||
$item->setPriceRials($price);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** نوبت گذشتهٔ ثبتشده — نمونهٔ آزمایش از همینها ساخته میشود. */
|
||||
private function pastAppointment(
|
||||
Doctor $doctor,
|
||||
User $patient,
|
||||
ServiceItem $service,
|
||||
Clinic|int $clinicId,
|
||||
int $daysAgo,
|
||||
int $price = 1_000_000,
|
||||
): Appointment {
|
||||
$start = time() - $daysAgo * 86400;
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $start, $start + 1200);
|
||||
$appointment->assignTenantPair('clinic', is_int($clinicId) ? $clinicId : (int) $clinicId->getId());
|
||||
$appointment->setServiceItem($service);
|
||||
$appointment->setVisitPriceRials($price);
|
||||
$appointment->setPatientName('بیمار نمونه');
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$appointment->transitionTo(Appointment::STATUS_COMPLETED);
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $body */
|
||||
private function draft(User $user, array $body): array
|
||||
{
|
||||
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $created['data'];
|
||||
}
|
||||
|
||||
/** @param string[] $tables */
|
||||
private function countRows(array $tables): array
|
||||
{
|
||||
$connection = $this->em->getConnection();
|
||||
$counts = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$counts[$table] = (int) $connection->fetchOne("SELECT COUNT(*) FROM $table");
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
// ── الگوها ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testTemplatesAreListedWithTheirInputs(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$body = $this->authJson('GET', '/api/v1/policy-templates', $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$keys = array_column($body['data'], 'key');
|
||||
|
||||
self::assertContains('min_days_between_sessions', $keys);
|
||||
self::assertContains('vip_discount', $keys);
|
||||
|
||||
$vip = current(array_filter($body['data'], static fn (array $t): bool => $t['key'] === 'vip_discount'));
|
||||
|
||||
self::assertSame('pricing', $vip['category']);
|
||||
self::assertSame(['visit_count', 'percent'], array_column($vip['inputs'], 'key'));
|
||||
}
|
||||
|
||||
/** الگو باید همان قانونی را بسازد که کاربر دستی میساخت — نه چیز دیگری. */
|
||||
public function testTemplateBuildsAValidPolicy(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'name' => 'تخفیف مشتری وفادار',
|
||||
'template' => 'vip_discount',
|
||||
'values' => ['visit_count' => 3, 'percent' => 15],
|
||||
]);
|
||||
|
||||
self::assertSame('pricing', $policy['category']);
|
||||
self::assertSame(
|
||||
[['field' => 'visit_count', 'operator' => 'greater_than', 'value' => 3]],
|
||||
$policy['condition']['conditions'],
|
||||
);
|
||||
self::assertSame([['type' => 'discount_percent', 'value' => 15]], $policy['effects']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ هر شش الگو باید قانونِ **معتبر** بسازند.
|
||||
*
|
||||
* الگو میانبُر است، نه مسیر دوم: اگر خروجی یکی از آنها از اعتبارسنجی عادی رد
|
||||
* نشود، کاربر با یک کلیک قانونی میسازد که هیچوقت کار نمیکند.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('templateCases')]
|
||||
public function testEveryTemplateBuildsAValidPolicy(string $key, array $values, string $category): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithBranch();
|
||||
|
||||
// الگوی نقشمحور به یک نوع منبع واقعی نیاز دارد.
|
||||
if (isset($values['role'])) {
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'surgeon',
|
||||
'name' => 'جراح',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$values['role'] = 'surgeon';
|
||||
}
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'name' => sprintf('الگوی %s', $key),
|
||||
'template' => $key,
|
||||
'values' => $values,
|
||||
]);
|
||||
|
||||
self::assertSame($category, $policy['category']);
|
||||
self::assertNotSame([], $policy['effects'], 'قانونی بدون اثر، قانون نیست');
|
||||
|
||||
// و باید از مسیر عادیِ آزمایش و فعالسازی رد شود.
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public static function templateCases(): array
|
||||
{
|
||||
return [
|
||||
'فاصلهٔ جلسات' => ['min_days_between_sessions', ['days' => 21], 'spacing'],
|
||||
'حداقل مدت' => ['complex_min_duration', ['minutes' => 60], 'timing'],
|
||||
'زمان اضافه' => ['extra_time_for_many_items', ['item_count' => 2, 'minutes' => 15], 'timing'],
|
||||
'نقش لازم' => ['surgery_needs_surgeon', ['role' => 'surgeon'], 'resource'],
|
||||
'رضایت والدین' => ['minor_needs_consent', ['age' => 18], 'eligibility'],
|
||||
'تخفیف وفادار' => ['vip_discount', ['visit_count' => 3, 'percent' => 15], 'pricing'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ آزمایش نباید هیچ نیمهحالتی جا بگذارد که **flushِ بعدیِ همین درخواست** ثبتش کند.
|
||||
*
|
||||
* این دقیقاً همان باگی است که `finally { rollback(); clear(); }` جلویش را میگیرد و
|
||||
* پیدا کردنش روزها میبرد: خطا در صفحهٔ آزمایش ظاهر نمیشود، در عملیاتِ بعدی ظاهر
|
||||
* میشود.
|
||||
*/
|
||||
public function testSimulationLeavesNoPendingStateForALaterFlush(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'قانون آزمایشی',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
$entity = static::getContainer()
|
||||
->get(\App\Policy\Repository\PolicyRepository::class)
|
||||
->findOneBy(['uuid' => $policy['uuid']]);
|
||||
|
||||
// یک entity در انتظار flush — دقیقاً وضعیتی که سناریوی خطرناک با آن شروع میشود.
|
||||
$pending = new \App\Resource\Entity\ResourceType(
|
||||
$address->tenantEntityType(),
|
||||
$address->tenantEntityId(),
|
||||
'pending_type',
|
||||
'نوع در انتظار',
|
||||
);
|
||||
$this->em->persist($pending);
|
||||
|
||||
static::getContainer()->get(\App\Policy\Simulation\PolicySimulator::class)->simulate($entity, 5);
|
||||
|
||||
self::assertSame(
|
||||
0,
|
||||
$this->em->getConnection()->getTransactionNestingLevel(),
|
||||
'تراکنش آزمایش باید بسته شده باشد',
|
||||
);
|
||||
|
||||
// flushِ بعدی نباید چیزی از قبل از آزمایش را ثبت کند.
|
||||
$this->em->flush();
|
||||
|
||||
$written = (int) $this->em->getConnection()->fetchOne(
|
||||
'SELECT COUNT(*) FROM resource_types WHERE code = ?',
|
||||
['pending_type'],
|
||||
);
|
||||
|
||||
self::assertSame(0, $written, 'آزمایش نباید حالتِ در انتظار را به ثبت برساند');
|
||||
}
|
||||
|
||||
public function testTemplateWithAMissingValueIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$this->authJson('POST', '/api/v1/policy', $user, [
|
||||
'name' => 'بدون مقدار',
|
||||
'template' => 'vip_discount',
|
||||
'values' => ['visit_count' => 3],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── شبیهسازی ───────────────────────────────────────────────────────────
|
||||
|
||||
/** ⭐ ارزشمندترین تست این تسک. */
|
||||
public function testSimulationWritesNothingButItsOwnRun(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'لیزر');
|
||||
$clinic = $address->getClinicId();
|
||||
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->pastAppointment($doctor, $user, $service, (int) $clinic, $i * 10);
|
||||
}
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف ۱۰٪',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 10]],
|
||||
]);
|
||||
|
||||
$tables = ['appointments', 'price_snapshots', 'resource_occupancy', 'policies', 'policy_version_logs'];
|
||||
$before = $this->countRows($tables);
|
||||
$runsBefore = $this->countRows(['policy_simulation_runs'])['policy_simulation_runs'];
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
self::assertSame($before, $this->countRows($tables), 'شبیهسازی نباید هیچ ردیفی بنویسد');
|
||||
|
||||
// دیتابیس تست هرگز ریست نمیشود، پس تفاوت شمرده میشود نه مقدار مطلق.
|
||||
self::assertSame(
|
||||
$runsBefore + 1,
|
||||
$this->countRows(['policy_simulation_runs'])['policy_simulation_runs'],
|
||||
'تنها ردیفی که باید نوشته شود، خودِ نتیجهٔ آزمایش است',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPricingSimulationShowsThePerAppointmentDifference(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'فیلر');
|
||||
|
||||
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 5, 2_000_000);
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'pricing',
|
||||
'name' => 'تخفیف ۲۵٪',
|
||||
'effects' => [['type' => 'discount_percent', 'value' => 25]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
|
||||
self::assertSame(1, $body['data']['sample_size']);
|
||||
self::assertSame(1, $body['data']['affected_count']);
|
||||
self::assertSame(100, $body['data']['affected_percent']);
|
||||
self::assertSame('high', $body['data']['severity']);
|
||||
|
||||
$row = $body['data']['rows'][0];
|
||||
|
||||
self::assertSame('2,000,000 ریال', $row['before']);
|
||||
self::assertSame('1,500,000 ریال', $row['after']);
|
||||
}
|
||||
|
||||
/** کلینیک تازه نوبتی ندارد؛ اگر این حالت خطا بود، هرگز قانونی فعال نمیکرد. */
|
||||
public function testEmptySampleSucceedsWithAWarning(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'حداقل ۳۰ دقیقه',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(0, $body['data']['sample_size']);
|
||||
self::assertSame('none', $body['data']['severity']);
|
||||
self::assertSame('دادهای برای آزمایش نیست', $body['data']['warning']);
|
||||
}
|
||||
|
||||
/** قانونی که همهٔ نمونه را رد میکند تقریباً همیشه اشتباه نوشته شده. */
|
||||
public function testAPolicyThatRejectsEverythingIsFlaggedHigh(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'بوتاکس');
|
||||
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), $i);
|
||||
}
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'selection',
|
||||
'name' => 'توقف کامل خدمت',
|
||||
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت متوقف است']],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
|
||||
self::assertSame(3, $body['data']['affected_count']);
|
||||
self::assertSame('high', $body['data']['severity']);
|
||||
self::assertSame('رد میشد', $body['data']['rows'][0]['after']);
|
||||
}
|
||||
|
||||
/** شرطی که هرگز برقرار نمیشود هم هشدار است، نه موفقیت. */
|
||||
public function testAPolicyThatMatchesNothingIsFlaggedNone(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
|
||||
$service = $this->service($section, 'مشاوره');
|
||||
|
||||
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 2);
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'فقط برای سبد بزرگ',
|
||||
'condition' => ['match' => 'all', 'conditions' => [
|
||||
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 50],
|
||||
]],
|
||||
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
|
||||
self::assertSame(1, $body['data']['sample_size']);
|
||||
self::assertSame(0, $body['data']['affected_count']);
|
||||
self::assertSame('none', $body['data']['severity']);
|
||||
}
|
||||
|
||||
// ── دروازهٔ فعالسازی ────────────────────────────────────────────────────
|
||||
|
||||
public function testActivateWithoutSimulationIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون آزمایشنشده',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ابتدا قانون را آزمایش کنید و نتیجه را ببینید', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
public function testSimulationOfTheOldVersionDoesNotUnlockTheNewOne(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون نسخهدار',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
|
||||
]);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||
self::assertSame(422, $this->responseCode(), 'آزمایش نسخهٔ ۱ نباید نسخهٔ ۲ را باز کند');
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
$activated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($activated['data']['active']);
|
||||
}
|
||||
|
||||
public function testSimulationHistoryIsListedNewestFirst(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون با تاریخچه',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}/simulations", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(2, $body['data']);
|
||||
}
|
||||
|
||||
public function testSampleSizeAboveTheCapIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($user, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون با نمونهٔ بزرگ',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user, ['sample_size' => 500]);
|
||||
|
||||
// سقف بیصدا اعمال نمیشود: کاربری که ۵۰۰ خواسته باید بداند نگرفته.
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testSimulatingAnotherClinicsPolicyIsNotFound(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithBranch();
|
||||
[$other] = $this->clinicWithBranch();
|
||||
|
||||
$policy = $this->draft($owner, [
|
||||
'category' => 'timing',
|
||||
'name' => 'قانون کلینیک اول',
|
||||
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
|
||||
]);
|
||||
|
||||
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $other);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Report;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دو رویدادی که مسیرشان از تسکهای قدیمیتر میآید: «نوبت انجام شد» و «نوبت جابهجا شد».
|
||||
*
|
||||
* هر دو از مسیرِ وضعیتِ موجود عبور میکنند، پس چیزی که این تستها نگه میدارند این است
|
||||
* که رویداد **بعد از ذخیرهٔ موفق** ثبت شود — نه هنگام درخواستِ تغییر وضعیت.
|
||||
*/
|
||||
class AppointmentLifecycleEventTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر رویداد');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function latest(string $name): ?DomainEventLog
|
||||
{
|
||||
return $this->em->getRepository(DomainEventLog::class)
|
||||
->findOneBy(['name' => $name], ['id' => 'DESC']);
|
||||
}
|
||||
|
||||
public function testCompletingAnAppointmentRecordsTheEvent(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = time() + 86_400;
|
||||
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
|
||||
'status' => Appointment::STATUS_CONFIRMED,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $appointment->getUuid()]);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}/status", $doctor->getUser(), [
|
||||
'status' => Appointment::STATUS_COMPLETED,
|
||||
'version' => $reloaded->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$event = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
|
||||
self::assertNotNull($event);
|
||||
self::assertSame($appointment->getUuid(), $event->getPayload()['appointment_uuid']);
|
||||
self::assertSame($start, $event->getPayload()['slot_start']);
|
||||
}
|
||||
|
||||
/**
|
||||
* انتقال ردشده نباید رویداد بگذارد؛ وگرنه گزارش «انجامشده»ها از خودِ نوبتها جلو میزند.
|
||||
*/
|
||||
public function testARejectedTransitionRecordsNothing(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = time() + 86_400;
|
||||
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$before = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
|
||||
|
||||
// `pending → completed` در جدول انتقالها نیست.
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
|
||||
'status' => Appointment::STATUS_COMPLETED,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$after = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
|
||||
self::assertSame($before?->getId(), $after?->getId());
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Report;
|
||||
|
||||
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\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رویدادهای دامنه و صندوق خروجی — تسک ۱۴.
|
||||
*
|
||||
* دو تضمین که کل الگو برایشان وجود دارد: رویداد **بعد از** commit منتشر میشود، و
|
||||
* هیچ رویدادی گم نمیشود.
|
||||
*/
|
||||
class DomainEventTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: PatientRecord} */
|
||||
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);
|
||||
|
||||
$patientUser = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $patient];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, 'لیزر فولبادی');
|
||||
$item->setSoloDurationMinutes(30);
|
||||
$item->setPriceRials(4_000_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function publisher(): DomainEventPublisher
|
||||
{
|
||||
return static::getContainer()->get(DomainEventPublisher::class);
|
||||
}
|
||||
|
||||
private function repo(): DomainEventLogRepository
|
||||
{
|
||||
return static::getContainer()->get(DomainEventLogRepository::class);
|
||||
}
|
||||
|
||||
private function containerEm(): EntityManagerInterface
|
||||
{
|
||||
return static::getContainer()->get(EntityManagerInterface::class);
|
||||
}
|
||||
|
||||
// ── قرارداد ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** نام رویداد قرارداد عمومی است؛ تایپو باید همانجا بترکد نه در سکوت. */
|
||||
public function testAnUnknownEventNameIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinic();
|
||||
|
||||
self::expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->publisher()->record('clinic', 1, 'AppointmentBookd', ['appointment_uuid' => 'x']);
|
||||
}
|
||||
|
||||
/** ⭐ payload فقط اسکالر و uuid — هیچ entity ای در رویداد نیست. */
|
||||
public function testNonScalarPayloadValuesAreDropped(): void
|
||||
{
|
||||
$event = new DomainEventLog('clinic', 1, DomainEvents::APPOINTMENT_BOOKED, [
|
||||
'appointment_uuid' => 'abc',
|
||||
'count' => 3,
|
||||
'nested' => ['a' => 1],
|
||||
'object' => new \stdClass(),
|
||||
]);
|
||||
|
||||
self::assertSame(['appointment_uuid' => 'abc', 'count' => 3], $event->getPayload());
|
||||
}
|
||||
|
||||
// ── انتشار بعد از commit ────────────────────────────────────────────────
|
||||
|
||||
/** ⭐⭐ تراکنشی که برمیگردد، هیچ رویدادی جا نمیگذارد. */
|
||||
public function testARolledBackTransactionLeavesNoEvent(): void
|
||||
{
|
||||
$this->clinic();
|
||||
|
||||
$before = $this->repo()->count([]);
|
||||
$em = $this->containerEm();
|
||||
|
||||
$em->beginTransaction();
|
||||
|
||||
try {
|
||||
$this->publisher()->record('clinic', 999, DomainEvents::APPOINTMENT_BOOKED, ['appointment_uuid' => 'ghost']);
|
||||
$em->flush();
|
||||
} finally {
|
||||
$em->rollback();
|
||||
$em->clear();
|
||||
}
|
||||
|
||||
self::assertSame($before, $this->repo()->count([]), 'رویداد نباید از تراکنشِ برگشته جا بماند');
|
||||
}
|
||||
|
||||
// ── صندوق خروجی ────────────────────────────────────────────────────────
|
||||
|
||||
public function testPendingEventsArePublishedAndMarked(): void
|
||||
{
|
||||
[$user, $section, $address, $patient] = $this->clinic();
|
||||
|
||||
$event = $this->publisher()->recordAndFlush(
|
||||
'clinic',
|
||||
(int) $address->getClinicId(),
|
||||
DomainEvents::PACKAGE_PURCHASED,
|
||||
['patient_package_uuid' => 'pkg-1'],
|
||||
);
|
||||
|
||||
self::assertNull($event->getPublishedAt());
|
||||
self::assertContains($event->getUuid(), array_map(
|
||||
static fn (DomainEventLog $e): string => $e->getUuid(),
|
||||
$this->repo()->findPending(500),
|
||||
));
|
||||
|
||||
$command = static::getContainer()->get(\App\Shared\Event\Command\PublishDomainEventsCommand::class);
|
||||
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
|
||||
$tester->execute(['--limit' => '500']);
|
||||
|
||||
$this->containerEm()->clear();
|
||||
|
||||
$reloaded = $this->repo()->findOneBy(['uuid' => $event->getUuid()]);
|
||||
|
||||
self::assertNotNull($reloaded->getPublishedAt(), 'رویداد باید منتشر و علامتگذاری شود');
|
||||
self::assertSame(0, $reloaded->getAttempts());
|
||||
}
|
||||
|
||||
/** ردیفی که سقف تلاش را رد کرده دیگر برداشته نمیشود، ولی حذف هم نمیشود. */
|
||||
public function testAnExhaustedEventIsNoLongerPickedUpButStays(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinic();
|
||||
|
||||
$event = $this->publisher()->recordAndFlush(
|
||||
'clinic',
|
||||
(int) $address->getClinicId(),
|
||||
DomainEvents::CREDIT_CONSUMED,
|
||||
['patient_package_uuid' => 'pkg-2'],
|
||||
);
|
||||
|
||||
for ($i = 0; $i < DomainEventLog::MAX_ATTEMPTS; $i++) {
|
||||
$event->markFailed('اتصال Redis برقرار نشد');
|
||||
}
|
||||
|
||||
$this->containerEm()->flush();
|
||||
|
||||
$pendingUuids = array_map(
|
||||
static fn (DomainEventLog $e): string => $e->getUuid(),
|
||||
$this->repo()->findPending(500),
|
||||
);
|
||||
|
||||
self::assertNotContains($event->getUuid(), $pendingUuids);
|
||||
self::assertNotNull($this->repo()->findOneBy(['uuid' => $event->getUuid()]), 'ردیف مرده باید بماند تا دیده شود');
|
||||
self::assertSame('اتصال Redis برقرار نشد', $event->getLastError());
|
||||
}
|
||||
|
||||
// ── رویدادهای واقعی ────────────────────────────────────────────────────
|
||||
|
||||
public function testSellingAPackageRecordsItsEvent(): void
|
||||
{
|
||||
[$user, $section, , $patient] = $this->clinic();
|
||||
$service = $this->service($section);
|
||||
|
||||
$package = $this->authJson('POST', '/api/v1/packages', $user, [
|
||||
'name' => '۶ جلسه',
|
||||
'session_count' => 6,
|
||||
'price_rials' => 10_000_000,
|
||||
'service_uuids' => [$service->getUuid()],
|
||||
])['data'];
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
|
||||
'package_uuid' => $package['uuid'],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$names = array_map(
|
||||
static fn (DomainEventLog $e): string => $e->getName(),
|
||||
$this->repo()->search(DomainEvents::PACKAGE_PURCHASED, null, null, 10),
|
||||
);
|
||||
|
||||
self::assertContains(DomainEvents::PACKAGE_PURCHASED, $names);
|
||||
}
|
||||
|
||||
public function testStartingACourseRecordsItsEvent(): void
|
||||
{
|
||||
[$user, $section, , $patient] = $this->clinic();
|
||||
$service = $this->service($section);
|
||||
|
||||
$protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'session_count' => 4,
|
||||
'min_days' => 7,
|
||||
'ideal_days' => 14,
|
||||
'max_days' => 21,
|
||||
])['data'];
|
||||
|
||||
$this->authJson('POST', '/api/v1/treatment-course', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'protocol_uuid' => $protocol['uuid'],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$events = $this->repo()->search(DomainEvents::COURSE_STARTED, null, null, 10);
|
||||
|
||||
self::assertNotEmpty($events);
|
||||
self::assertArrayHasKey('course_uuid', $events[0]->getPayload());
|
||||
}
|
||||
|
||||
// ── دسترسی ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testOnlyAdminsCanReadTheEventLog(): void
|
||||
{
|
||||
[$user] = $this->clinic();
|
||||
|
||||
$this->authJson('GET', '/api/v1/domain-events', $user);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/domain-events', $admin);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Report;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* گزارش بهرهوری منابع و دقت برنامه — تسک ۱۴.
|
||||
*
|
||||
* گزارش دقت برنامه تنها بازخوردی است که به کلینیک میگوید تعریف بخشهایش درست است یا
|
||||
* نه؛ بدون آن، ابزار قدرتمند تسک ۰۵ کور کار میکند.
|
||||
*/
|
||||
class ReportTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
|
||||
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);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر گزارش');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor];
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name, int $solo): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes($solo);
|
||||
$item->setPriceRials(1_000_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** نوبت انجامشده با مدت پیشبینی و مدت واقعی مشخص. */
|
||||
private function completed(
|
||||
Doctor $doctor,
|
||||
User $patient,
|
||||
ServiceItem $service,
|
||||
int $clinicId,
|
||||
int $plannedMinutes,
|
||||
int $actualMinutes,
|
||||
int $daysAgo,
|
||||
): Appointment {
|
||||
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$start = time() - $daysAgo * 86400 + (++$this->slotCursor) * 60;
|
||||
|
||||
$appointment = new Appointment(
|
||||
$em->getRepository(Doctor::class)->find($doctor->getId()),
|
||||
$em->getRepository(User::class)->find($patient->getId()),
|
||||
$start,
|
||||
$start + $actualMinutes * 60,
|
||||
);
|
||||
$appointment->assignTenantPair('clinic', $clinicId);
|
||||
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
|
||||
$appointment->setPatientName('بیمار گزارش');
|
||||
$appointment->setServiceDuration($plannedMinutes, 0);
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$appointment->transitionTo(Appointment::STATUS_COMPLETED);
|
||||
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
// ── دقت برنامه ──────────────────────────────────────────────────────────
|
||||
|
||||
/** ⭐ سرویسی که ۶۰ دقیقه پیشبینی شده ولی ۹۰ دقیقه طول میکشد. */
|
||||
public function testAServiceThatRunsLongIsFlaggedHigh(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinic();
|
||||
$service = $this->service($section, 'لیزر فولبادی', 60);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i);
|
||||
}
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$row = $body['data']['rows'][0];
|
||||
|
||||
self::assertSame($service->getUuid(), $row['service_uuid']);
|
||||
self::assertSame(60, $row['planned_minutes']);
|
||||
self::assertSame(90, $row['actual_minutes']);
|
||||
self::assertSame(50, $row['deviation_percent']);
|
||||
self::assertSame('high', $row['severity']);
|
||||
}
|
||||
|
||||
/** انحراف منفی هم غلط است: ظرفیتی که میشد فروخت، خالی مانده. */
|
||||
public function testAServiceThatRunsShortIsAlsoFlagged(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinic();
|
||||
$service = $this->service($section, 'مشاوره', 60);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 30, $i);
|
||||
}
|
||||
|
||||
$rows = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$user,
|
||||
)['data']['rows'];
|
||||
|
||||
self::assertSame(-50, $rows[0]['deviation_percent']);
|
||||
self::assertSame('high', $rows[0]['severity']);
|
||||
}
|
||||
|
||||
/** زیر سه نمونه، میانگین معنا ندارد. */
|
||||
/**
|
||||
* ⭐ زیر آستانه **حذف نمیشود، بیشدت برمیگردد**.
|
||||
*
|
||||
* میانگین دو نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش یعنی
|
||||
* کلینیک کوچک گزارشی خالی میبیند و فکر میکند همهچیز درست است.
|
||||
*/
|
||||
public function testASmallSampleIsShownWithoutASeverity(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinic();
|
||||
$service = $this->service($section, 'خدمت کمتکرار', 60);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 1);
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 2);
|
||||
|
||||
$rows = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$user,
|
||||
)['data']['rows'];
|
||||
|
||||
$mine = array_values(array_filter(
|
||||
$rows,
|
||||
static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(),
|
||||
));
|
||||
|
||||
self::assertCount(1, $mine);
|
||||
self::assertNull($mine[0]['severity'], 'با دو نمونه نباید شدتی ادعا شود');
|
||||
self::assertTrue($mine[0]['below_min_sample']);
|
||||
self::assertSame(2, $mine[0]['sample_size']);
|
||||
}
|
||||
|
||||
public function testAnAccurateServiceHasNoSeverity(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinic();
|
||||
$service = $this->service($section, 'خدمت دقیق', 60);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 60, $i);
|
||||
}
|
||||
|
||||
$rows = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$user,
|
||||
)['data']['rows'];
|
||||
|
||||
$row = current(array_filter($rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid()));
|
||||
|
||||
self::assertSame(0, $row['deviation_percent']);
|
||||
self::assertSame('none', $row['severity']);
|
||||
}
|
||||
|
||||
// ── بهرهوری منابع ──────────────────────────────────────────────────────
|
||||
|
||||
/** منبعی بدون تقویم «۰٪ بهرهوری» ندارد — بهرهوریاش تعریفنشده است. */
|
||||
/**
|
||||
* ⭐ چهار آستانه، هر کدام روی مرز خودش.
|
||||
*
|
||||
* آستانهای که یک درجه اشتباه بیفتد، یا همهچیز را قرمز میکند (و کسی دیگر نگاه
|
||||
* نمیکند) یا هیچچیز را (و گزارش بیفایده است).
|
||||
*
|
||||
* @param int $planned مدت برنامه
|
||||
* @param int $actual مدت واقعی
|
||||
*/
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('severityCases')]
|
||||
public function testEachSeverityThresholdIsHitExactly(int $planned, int $actual, string $expected): void
|
||||
{
|
||||
[$user, $section, $address, $doctor] = $this->clinic();
|
||||
$service = $this->service($section, 'خدمت آستانه', $planned);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), $planned, $actual, $i);
|
||||
}
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$user,
|
||||
);
|
||||
|
||||
$row = $body['data']['rows'][0];
|
||||
|
||||
self::assertSame($expected, $row['severity'], sprintf(
|
||||
'انحراف %d%%',
|
||||
$row['deviation_percent'],
|
||||
));
|
||||
}
|
||||
|
||||
/** مرزها: ۳۰ · ۱۵ · ۵ درصد، روی قدر مطلق. */
|
||||
public static function severityCases(): array
|
||||
{
|
||||
return [
|
||||
'دقیقاً روی مرز high' => [100, 130, 'high'],
|
||||
'یک قدم زیر high' => [100, 129, 'medium'],
|
||||
'دقیقاً روی مرز medium' => [100, 115, 'medium'],
|
||||
'یک قدم زیر medium' => [100, 114, 'low'],
|
||||
'دقیقاً روی مرز low' => [100, 105, 'low'],
|
||||
'یک قدم زیر low' => [100, 104, 'none'],
|
||||
'کوتاهتر هم شمرده میشود' => [100, 70, 'high'],
|
||||
];
|
||||
}
|
||||
|
||||
public function testAResourceWithoutACalendarHasNullUtilization(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinic();
|
||||
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'device',
|
||||
'name' => 'دستگاه',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => 'لیزر ۱',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
sprintf(
|
||||
'/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d',
|
||||
$address->getUuid(),
|
||||
time() - 7 * 86400,
|
||||
time(),
|
||||
),
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$row = $body['data']['rows'][0];
|
||||
|
||||
self::assertSame('لیزر ۱', $row['resource_name']);
|
||||
self::assertSame(0, $row['available_minutes']);
|
||||
self::assertNull($row['utilization'], 'تقسیم بر صفر معنای متفاوتی دارد');
|
||||
self::assertNull($row['active_ratio']);
|
||||
self::assertFalse($row['wasted_capacity']);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ سنجههای واقعی: اشغال شامل انتظار است، «کار مفید» نه.
|
||||
*
|
||||
* فاصلهٔ این دو همان چیزی است که تعریف غلط بخشها را لو میدهد؛ اگر هر دو یکی
|
||||
* برگردند، گزارش بیفایده است و کسی متوجه نمیشود.
|
||||
*/
|
||||
public function testOccupiedIncludesTheWaitingSegmentButActiveDoesNot(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinic();
|
||||
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'room',
|
||||
'name' => 'اتاق',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => 'اتاق ۱',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$resource = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class)
|
||||
->findOneBy(['uuid' => $created['data']['uuid']]);
|
||||
|
||||
$from = time() - 2 * 86400;
|
||||
$start = $from + 3600;
|
||||
|
||||
$appointment = $this->bookedAppointment($address, $start, 60);
|
||||
|
||||
// یک ساعت اشغال؛ ولی بیمار فقط ۲۰ دقیقهٔ اولش حاضر است.
|
||||
$this->occupy($resource, $appointment, $start, $start + 3600);
|
||||
$this->segment($appointment, 1, 'ویزیت', $start, $start + 1200, true);
|
||||
$this->segment($appointment, 2, 'انتظار', $start + 1200, $start + 3600, false);
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
sprintf(
|
||||
'/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d',
|
||||
$address->getUuid(),
|
||||
$from,
|
||||
time(),
|
||||
),
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$row = $body['data']['rows'][0];
|
||||
|
||||
self::assertSame(60, $row['occupied_minutes'], 'انتظار هم اشغال است');
|
||||
self::assertSame(20, $row['active_minutes'], 'ولی کار مفید نیست');
|
||||
}
|
||||
|
||||
private function bookedAppointment(\App\Doctor\Entity\DoctorAddress $address, int $start, int $minutes): \App\Appointment\Entity\Appointment
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر گزارش');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$appointment = new \App\Appointment\Entity\Appointment(
|
||||
$doctor,
|
||||
$this->createUser(['ROLE_USER']),
|
||||
$start,
|
||||
$start + $minutes * 60,
|
||||
);
|
||||
$appointment->assignTenantPair('clinic', (int) $address->getClinicId());
|
||||
$appointment->setAddressId($address->getId());
|
||||
$appointment->setPatientName('بیمار گزارش');
|
||||
$appointment->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED);
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function occupy(
|
||||
\App\Resource\Entity\ClinicResource $resource,
|
||||
\App\Appointment\Entity\Appointment $appointment,
|
||||
int $from,
|
||||
int $to,
|
||||
): void {
|
||||
$row = new \App\Appointment\Availability\Entity\ResourceOccupancy(
|
||||
$resource,
|
||||
$from,
|
||||
$to,
|
||||
\App\Appointment\Availability\Entity\ResourceOccupancy::STATUS_BOOKED,
|
||||
);
|
||||
$row->setAppointmentId($appointment->getId());
|
||||
|
||||
$this->em->persist($row);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function segment(
|
||||
\App\Appointment\Entity\Appointment $appointment,
|
||||
int $sequence,
|
||||
string $name,
|
||||
int $from,
|
||||
int $to,
|
||||
bool $present,
|
||||
): void {
|
||||
$this->em->persist(new \App\Appointment\Booking\Entity\AppointmentSegment(
|
||||
$appointment,
|
||||
$sequence,
|
||||
$name,
|
||||
$from,
|
||||
$to,
|
||||
$present,
|
||||
));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ اشغال و کار مفید هرکدام **یک** کوئریاند، مستقل از تعداد منبع.
|
||||
*
|
||||
* پیمایش per منبع روی کلینیکی با ۴۰ منبع یعنی ۸۰ کوئری برای یک گزارش. تعداد
|
||||
* دقیقش مهم نیست؛ چیزی که این تست نگه میدارد این است که با سه برابر شدن منابع،
|
||||
* تعداد کوئریها سه برابر **نشود**.
|
||||
*/
|
||||
public function testQueryCountDoesNotGrowWithTheNumberOfResources(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinic();
|
||||
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'room',
|
||||
'name' => 'اتاق',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$reporter = static::getContainer()->get(\App\Report\Service\ResourceUtilizationReporter::class);
|
||||
|
||||
$count = function (int $resources) use ($user, $address, $type, $reporter): int {
|
||||
for ($i = 0; $i < $resources; $i++) {
|
||||
$this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => sprintf('اتاق %d', $i + 1),
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
$all = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class)
|
||||
->findBy(['address' => $address]);
|
||||
|
||||
$connection = $this->em->getConnection();
|
||||
$before = $this->queryCount($connection);
|
||||
|
||||
$reporter->report($all, $address, time() - 7 * 86400, time());
|
||||
|
||||
return $this->queryCount($connection) - $before;
|
||||
};
|
||||
|
||||
$withOne = $count(1);
|
||||
$withMany = $count(5);
|
||||
|
||||
self::assertLessThan(
|
||||
$withOne * 3,
|
||||
$withMany,
|
||||
sprintf('یک منبع %d کوئری، شش منبع %d کوئری — رشد خطی است', $withOne, $withMany),
|
||||
);
|
||||
}
|
||||
|
||||
/** شمار کوئری از خودِ سرور — `SHOW SESSION STATUS` روی همان اتصال. */
|
||||
private function queryCount(\Doctrine\DBAL\Connection $connection): int
|
||||
{
|
||||
return (int) ($connection->fetchAssociative("SHOW SESSION STATUS LIKE 'Questions'")['Value'] ?? 0);
|
||||
}
|
||||
|
||||
// ── محدودیت بازه و دسترسی ───────────────────────────────────────────────
|
||||
|
||||
public function testARangeLongerThanNinetyDaysIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinic();
|
||||
|
||||
$this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 200 * 86400, time()),
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnInvertedRangeIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinic();
|
||||
|
||||
$this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time(), time() - 86400),
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnotherClinicSeesItsOwnNumbersOnly(): void
|
||||
{
|
||||
[$owner, $section, $address, $doctor] = $this->clinic();
|
||||
[$other] = $this->clinic();
|
||||
|
||||
$service = $this->service($section, 'لیزر', 60);
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i);
|
||||
}
|
||||
|
||||
$rows = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
|
||||
$other,
|
||||
)['data']['rows'];
|
||||
|
||||
self::assertSame([], array_values(array_filter(
|
||||
$rows,
|
||||
static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Waitlist;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Waitlist\Entity\WaitlistEntry;
|
||||
use App\Waitlist\Repository\WaitlistEntryRepository;
|
||||
use App\Waitlist\Service\WaitlistNotifier;
|
||||
|
||||
/**
|
||||
* لیست انتظار — تسک ۱۳.
|
||||
*
|
||||
* تصمیم معماری: ظرفیت آزادشده **به همه** خبر داده میشود و اولین رزروکننده میبرد.
|
||||
* صف انحصاری یعنی وقتی که کسی جوابش را نمیدهد نیم ساعت قفل بماند، و ظرفیتِ دو ساعت
|
||||
* مانده به نوبت آن نیم ساعت را ندارد.
|
||||
*/
|
||||
class WaitlistTest extends ApiTestCase
|
||||
{
|
||||
private int $slotCursor = 0;
|
||||
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
|
||||
private function clinicWithPatient(): 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);
|
||||
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر انتظار');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$patientUser = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $section, $address, $doctor, $patient];
|
||||
}
|
||||
|
||||
private function extraPatient(int $clinicId): PatientRecord
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId);
|
||||
$this->em->persist($patient);
|
||||
$this->em->flush();
|
||||
|
||||
return $patient;
|
||||
}
|
||||
|
||||
private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name);
|
||||
$item->setSoloDurationMinutes(30);
|
||||
$item->setPriceRials(4_000_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $extra */
|
||||
private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array
|
||||
{
|
||||
$body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => $from,
|
||||
'desired_to' => $to,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $body['data'];
|
||||
}
|
||||
|
||||
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment
|
||||
{
|
||||
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
|
||||
$start += (++$this->slotCursor) * 60;
|
||||
|
||||
$appointment = new Appointment(
|
||||
$em->getRepository(Doctor::class)->find($doctor->getId()),
|
||||
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
|
||||
$start,
|
||||
$start + 1800,
|
||||
);
|
||||
$appointment->assignTenantPair('clinic', (int) $address->getClinicId());
|
||||
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
|
||||
$appointment->setAddressId($address->getId());
|
||||
$appointment->setPatientName('بیمار نوبت');
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
|
||||
$em->persist($appointment);
|
||||
$em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function notifier(): WaitlistNotifier
|
||||
{
|
||||
return static::getContainer()->get(WaitlistNotifier::class);
|
||||
}
|
||||
|
||||
// ── ثبت ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testJoiningTheWaitlistStoresTheWindow(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$from = time() + 86400;
|
||||
$to = $from + 3 * 86400;
|
||||
$entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]);
|
||||
|
||||
self::assertSame('waiting', $entry['status']);
|
||||
self::assertSame($from, $entry['desired_from']);
|
||||
self::assertSame(['evening'], $entry['preferred_day_parts']);
|
||||
self::assertSame(0, $entry['notify_count']);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/waitlist', $user);
|
||||
self::assertCount(1, $list['data']);
|
||||
}
|
||||
|
||||
/** انتظار برای بازهٔ گذشته هرگز به نتیجه نمیرسد. */
|
||||
public function testAPastWindowIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->authJson('POST', '/api/v1/waitlist', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => time() - 5 * 86400,
|
||||
'desired_to' => time() - 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnInvertedWindowIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->authJson('POST', '/api/v1/waitlist', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => time() + 5 * 86400,
|
||||
'desired_to' => time() + 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── تطبیق و اطلاع ───────────────────────────────────────────────────────
|
||||
|
||||
public function testMatchesFindsEveryoneWaitingForThatMoment(): void
|
||||
{
|
||||
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$from = time() + 86400;
|
||||
$to = $from + 3 * 86400;
|
||||
|
||||
$this->join($user, $patient, $service, $from, $to);
|
||||
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
|
||||
|
||||
// کسی که بازهاش پوشش نمیدهد نباید بیاید.
|
||||
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400);
|
||||
|
||||
$start = $from + 3600;
|
||||
$matches = $this->authJson(
|
||||
'GET',
|
||||
sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start),
|
||||
$user,
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE));
|
||||
self::assertCount(2, $matches['data']);
|
||||
}
|
||||
|
||||
/** ⭐ همه خبر میشوند — نه فقط نفر اول. */
|
||||
public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$slot = time() + 2 * 86400;
|
||||
$from = $slot - 86400;
|
||||
$to = $slot + 86400;
|
||||
|
||||
$first = $this->join($user, $patient, $service, $from, $to);
|
||||
$second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(2, $body['data']['waitlist_notified']);
|
||||
|
||||
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
|
||||
|
||||
foreach ([$first['uuid'], $second['uuid']] as $uuid) {
|
||||
$entry = $repo->findByUuid($uuid);
|
||||
self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus());
|
||||
self::assertNotNull($entry->getNotifiedAt());
|
||||
self::assertSame(1, $entry->getNotifyCount());
|
||||
}
|
||||
}
|
||||
|
||||
/** سقف اطلاعرسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمیکند. */
|
||||
public function testNotificationsStopAtTheCap(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$slot = time() + 2 * 86400;
|
||||
$entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400);
|
||||
|
||||
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
|
||||
|
||||
for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) {
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
|
||||
$this->notifier()->notifyForFreedSlot($appointment);
|
||||
}
|
||||
|
||||
self::assertSame(
|
||||
WaitlistEntry::MAX_NOTIFICATIONS,
|
||||
$repo->findByUuid($entry['uuid'])->getNotifyCount(),
|
||||
);
|
||||
}
|
||||
|
||||
/** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمیشود. */
|
||||
public function testAnEntryForAnotherBranchIsNotNotified(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$otherBranch = DoctorAddress::forClinic((int) $address->getClinicId());
|
||||
$otherBranch->setName('شعبهٔ دوم');
|
||||
$this->em->persist($otherBranch);
|
||||
$this->em->flush();
|
||||
|
||||
$slot = time() + 2 * 86400;
|
||||
|
||||
$this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [
|
||||
'branch_uuid' => $otherBranch->getUuid(),
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
|
||||
|
||||
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
public function testDeletingAnEntryRemovesItFromTheList(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']);
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
[$other] = $this->clinicWithPatient();
|
||||
|
||||
$service = $this->service($section);
|
||||
$entry = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400);
|
||||
|
||||
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── بخش روز ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره.
|
||||
*
|
||||
* ذخیرهکردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار
|
||||
* فکر میکند سیستم حرفش را شنیده.
|
||||
*/
|
||||
public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$morning = $this->localHour(9);
|
||||
|
||||
$this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [
|
||||
'preferred_day_parts' => ['evening'],
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $morning);
|
||||
|
||||
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$evening = $this->localHour(19);
|
||||
|
||||
$this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [
|
||||
'preferred_day_parts' => ['evening'],
|
||||
]);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $evening);
|
||||
|
||||
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
/** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */
|
||||
public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$dawn = $this->localHour(5);
|
||||
|
||||
$this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $dawn);
|
||||
|
||||
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
|
||||
}
|
||||
|
||||
public function testAnUnknownDayPartIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$this->authJson('POST', '/api/v1/waitlist', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => time() + 86400,
|
||||
'desired_to' => time() + 3 * 86400,
|
||||
'preferred_day_parts' => ['midnight'],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── مرزها و چرخهٔ عمر ───────────────────────────────────────────────────
|
||||
|
||||
public function testARangeLongerThanNinetyDaysIsRejected(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$from = time() + 86400;
|
||||
|
||||
$this->authJson('POST', '/api/v1/waitlist', $user, [
|
||||
'patient_uuid' => $patient->getUuid(),
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'desired_from' => $from,
|
||||
'desired_to' => $from + 91 * 86400,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ ردیفِ منقضی از قبل هم در تطبیق نمیآمد؛ این پاکسازیِ **نمایش** است تا اپراتور
|
||||
* بفهمد کدام انتظار هنوز زنده است.
|
||||
*/
|
||||
public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void
|
||||
{
|
||||
[$user, $section, , , $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400);
|
||||
$dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400);
|
||||
|
||||
// بازهٔ ردیف دوم را به گذشته میبریم — API عمداً بازهٔ گذشته را نمیپذیرد.
|
||||
$this->em->getConnection()->executeStatement(
|
||||
'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?',
|
||||
[time() - 10 * 86400, time() - 86400, $dead['uuid']],
|
||||
);
|
||||
|
||||
$expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire();
|
||||
self::assertSame(1, $expired);
|
||||
|
||||
// خواندن مستقیم از دیتابیس: `expire()` با SQL خام مینویسد، پس هر نقشهٔ هویتِ
|
||||
// باز، نسخهٔ کهنه را برمیگرداند.
|
||||
self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid']));
|
||||
self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid']));
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای
|
||||
* چیزی که هنوز منتظرش است دیگر هرگز خبر نمیشود.
|
||||
*/
|
||||
public function testBookingConvertsOnlyTheMatchingEntry(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
$other = $this->service($section, 'بوتاکس');
|
||||
|
||||
$start = time() + 2 * 86400;
|
||||
|
||||
$mine = $this->join($user, $patient, $service, $start - 86400, $start + 86400);
|
||||
$unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400);
|
||||
|
||||
$appointment = $this->appointment($doctor, $patient, $service, $address, $start);
|
||||
|
||||
$converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class);
|
||||
self::assertSame(1, $converter->convertFor($appointment));
|
||||
|
||||
// اجرای دوباره چیزی را دوباره نمیبندد — تحویل دوبارهٔ پیام بیخطر است.
|
||||
self::assertSame(0, $converter->convertFor($appointment));
|
||||
|
||||
$this->em->clear();
|
||||
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
|
||||
|
||||
self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus());
|
||||
self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus());
|
||||
}
|
||||
|
||||
private function statusOf(string $uuid): string
|
||||
{
|
||||
return (string) $this->em->getConnection()->fetchOne(
|
||||
'SELECT status FROM waitlist_entries WHERE uuid = ?',
|
||||
[$uuid],
|
||||
);
|
||||
}
|
||||
|
||||
/** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */
|
||||
private function localHour(int $hour): int
|
||||
{
|
||||
return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE)))
|
||||
->setTime($hour, 0)
|
||||
->getTimestamp();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user