Writing the query-count test that task 14 owed showed the growth was real: one resource cost 10 queries, six cost 33 — about five per resource, because the available-minutes figure walked each resource's calendar on its own. Holidays, tenant overrides and branch hours are identical for every resource in a report, so they now load once outside the loop; shifts and exceptions load for all resources in one query each. The batched path is a new method rather than a change to rawAvailability, which the booking engine also calls. The test pins the shape of the growth, not an exact count. Also landed: - app:segment:seed-templates with beauty, dental and physio presets. Building four segments and their requirements by hand is the first thing a new clinic must do and the most tedious; this gives them something to edit instead of an empty page. It refuses to touch a service that already has segments unless --force, and it will not invent resource types the tenant never defined. - book-all is all-or-nothing, proven rather than asserted: with a calendar open one day a week and a 1-2 day protocol gap, session one finds a slot and session two cannot, and every session must come back planned. - credit_refundable: false takes the credit back with a negative adjustment and deletes nothing — the ledger stays append-only. - the segments editor has frontend tests, including that it sends back what the user sees and renders read-only without the permission. useBranches now returns [] for a non-array payload instead of throwing "branches.map is not a function" and taking the page down with it. BookingLocationsScanTest built a Clinic around a Doctor loaded from a different manager, which Doctrine treats as a new entity; it flushed fine most runs and failed on cascade in others. It now loads the doctor from the same manager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
586 lines
26 KiB
PHP
586 lines
26 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());
|
||
}
|
||
|
||
/**
|
||
* ⭐ `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());
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|