Files
clinicpro/tests/Course/TreatmentCourseTest.php
T
hamedandClaude Opus 5 e9e61adfee feat(course): show how the course is actually going, not just how it was planned
Three gaps on the treatment-course page, all of them about the difference
between the protocol and reality.

The sessions table listed each date but not the gap between them, leaving the
operator to subtract two Jalali dates in their head. It now shows the real gap
and colours it as a warning past the protocol maximum.

A course cancelled mid-way stretches silently: the session goes back to
planned and nobody is told. The suggestion endpoint does warn, but only once a
branch is picked, so the warning could go unseen indefinitely. The page now
derives "N days since the last session, past the protocol maximum" from the
course itself, so it shows immediately.

The course's preferred resource was applied by the engine but never named in
the UI. The API now returns preferred_resource_name alongside the uuid, and
the text says plainly that it is a preference — the engine moves it up the
list, it does not hold the slot.

Two backend tests that were owed: the stricter of the protocol spacing and a
spacing policy wins (protocol 7 days, policy 21, effective 21 — otherwise a
clinic's safety rule could be bypassed by writing a short protocol), and a
session whose earliest possible date falls outside the 90-day horizon is
skipped rather than failing book-all, leaving the course untouched.

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

523 lines
23 KiB
PHP

<?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());
}
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);
}
}
}