Laser is six to eight sessions; the previous design only knew single appointments, which is the exception rather than the rule. - CourseProtocol per service: session count and three distinct spacings — min is the earliest that is clinically allowed, ideal is best, max is where the course starts losing its effect - Starting a course creates every session up front as `planned` and copies the protocol's numbers and per-session params, so changing the protocol tomorrow leaves a running course alone - Suggestions anchor on the last *completed* session, not the course start: when session 2 slips, session 3 moves with it - Slots are ranked by distance from ideal, not by earliest available — day 21 is worse than day 27 when 28 is the target - book-all is all-or-nothing inside one transaction, with a moving anchor and a 90-day horizon; sessions past the horizon stay planned and are reported, not treated as failures - The effective minimum is the stricter of the protocol and the task-09 spacing policy, so a clinic rule never fights the protocol - Cancelling one session returns only that session to planned; abandoning a course does not cancel its appointments, which stays an explicit decision One active course per (patient, service) via active_course_key, the same partial-uniqueness trick as Appointment::activeSlotKey. Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the patient record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
454 lines
20 KiB
PHP
454 lines
20 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']);
|
|
}
|
|
|
|
// ── جداسازی محیط ────────────────────────────────────────────────────────
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|