The last structural gap from task 05 was the third occupancy mode. It is passive: the resource is genuinely held — nobody else can take that room while the patient waits for the anaesthetic — but the time is not work done. It blocks exactly like exclusive; the difference is in the report, where without it a room that spends half its day waiting reads as fully utilised. The mode is validated, offered in the segment editor and carried through to the plan. Everything else that was still marked as a deviation is now recorded in docs/architecture/deviations.md, one row each, in the form "what the plan said / what was built / why". That includes the ones I would defend (five plan services collapsed into one builder that only build() calls; a Skill foreign key instead of a JSON array, because a deleted skill in JSON fails silently) and the ones that are simply facts about the product (service_option does not exist here, so a column for it would sit empty until someone read it as a bug). The i18n section says plainly that the product is single-language and describes the order to migrate in if that changes — a translation layer with one language is an indirection, not an abstraction. All sixteen checklists now read zero pending and zero unresolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
670 lines
30 KiB
PHP
670 lines
30 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());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ⭐ مسیر **موفق** `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);
|
||
}
|
||
}
|
||
}
|