A protocol says a course of a service runs over several sessions, when each
falls due, which doctor supervises it and which staff may perform it. The row
existing IS the "طول درمان" switch, so there is no separate boolean that could
disagree with the step list.
Each step's offset is measured from the previous session rather than from the
start of the course: laser spacing is a clinical requirement — hair regrows
relative to the last treatment — so a late patient shifts the rest of their
course instead of getting the next session early. That also lets one course use
uneven gaps, which a single min/ideal/max triple cannot express: a botox course
is session 1, then +15 days, then monthly.
Steps and staff are cleared and rewritten in two flushes inside a transaction.
A single flush sends inserts before deletes and the replacement row collides
with the unique (protocol, step_number) index — caught by the replace test.
Removes docs/api/course.md and the task-12 folder. They documented src/Course/,
a module deleted in 65d5831c whose commit message only mentions removing two
test files; that design is superseded by this one.
ServiceItem::$sessionCount is marked deprecated. It never had logic behind it
and session count now comes from the protocol; the column stays in payloads so
existing clients keep working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
PHP
280 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Treatment;
|
|
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Staff\Entity\ClinicStaff;
|
|
use App\Tests\ApiTestCase;
|
|
use App\Treatment\Entity\TreatmentProtocol;
|
|
|
|
class TreatmentProtocolTest extends ApiTestCase
|
|
{
|
|
private function clinicWithService(): array
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
|
$clinic = new Clinic($user);
|
|
$clinic->setName('کلینیک زیبایی آزمون');
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$address->setName('شعبهٔ مرکزی');
|
|
$this->em->persist($address);
|
|
|
|
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
|
$this->em->persist($section);
|
|
|
|
$service = new ServiceItem($section, 'لیزر فول بادی', 10_000_000);
|
|
$this->em->persist($service);
|
|
$this->em->flush();
|
|
|
|
return [$user, $clinic, $service, $address];
|
|
}
|
|
|
|
private function staffFor(Clinic $clinic, string $name): ClinicStaff
|
|
{
|
|
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), $name);
|
|
$this->em->persist($staff);
|
|
$this->em->flush();
|
|
|
|
return $staff;
|
|
}
|
|
|
|
private function doctorIn(Clinic $clinic): Doctor
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($user, 'دکتر ناظر');
|
|
$doctor->setMobileNumber($user->getMobileNumber());
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
/** سناریوی بوتاکس: جلسه ۲ بعد از ۱۵ روز، بقیه ماهانه — فاصلهٔ ثابت این را نمیگیرد. */
|
|
public function testProtocolWithUnevenIntervalsIsStoredAndReadBack(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
$supervisor = $this->doctorIn($clinic);
|
|
$uri = '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol';
|
|
|
|
$body = $this->authJson('PUT', $uri, $user, [
|
|
'supervisor_doctor_uuid' => $supervisor->getUuid(),
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 15],
|
|
['step_number' => 3, 'offset_days' => 30],
|
|
['step_number' => 4, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
self::assertSame(4, $body['data']['total_sessions']);
|
|
self::assertSame([0, 15, 30, 30], array_column($body['data']['steps'], 'offset_days'));
|
|
self::assertSame($supervisor->getUuid(), $body['data']['supervisor']['uuid']);
|
|
self::assertSame([$staff->getUuid()], array_column($body['data']['staff'], 'uuid'));
|
|
|
|
$read = $this->authJson('GET', $uri, $user);
|
|
self::assertSame([0, 15, 30, 30], array_column($read['data']['steps'], 'offset_days'));
|
|
}
|
|
|
|
/** سرویس بدون پروتکل یعنی سوییچ خاموش، نه «پیدا نشد». */
|
|
public function testServiceWithoutProtocolReturnsNull(): void
|
|
{
|
|
[$user, , $service] = $this->clinicWithService();
|
|
|
|
$body = $this->authJson('GET', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertNull($body['data']);
|
|
}
|
|
|
|
public function testReplacingAProtocolDropsTheOldSteps(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
$uri = '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol';
|
|
|
|
$this->authJson('PUT', $uri, $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 30],
|
|
['step_number' => 3, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
$body = $this->authJson('PUT', $uri, $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 21],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
|
self::assertSame(2, $body['data']['total_sessions']);
|
|
self::assertSame([0, 21], array_column($body['data']['steps'], 'offset_days'));
|
|
}
|
|
|
|
public function testDeleteTurnsTheSwitchOff(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
$uri = '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol';
|
|
|
|
$this->authJson('PUT', $uri, $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
$this->authJson('DELETE', $uri, $user);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$body = $this->authJson('GET', $uri, $user);
|
|
self::assertNull($body['data']);
|
|
}
|
|
|
|
/** یک گام یعنی سرویس تکجلسهای — یعنی سوییچ اصلاً نباید روشن میشد. */
|
|
public function testSingleStepProtocolIsRejected(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
|
|
$body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [['step_number' => 1, 'offset_days' => 0]],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('steps', $body['errors'][0]['field']);
|
|
}
|
|
|
|
public function testFirstStepMustHaveZeroOffset(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
|
|
$body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 10],
|
|
['step_number' => 2, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('offset_days', $body['errors'][0]['field']);
|
|
}
|
|
|
|
public function testLaterStepsNeedAPositiveOffset(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
|
|
$body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 0],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('offset_days', $body['errors'][0]['field']);
|
|
}
|
|
|
|
/** «جلسهٔ ۳ از ۸» فقط وقتی معنی دارد که گامی جا نیفتاده باشد. */
|
|
public function testStepNumbersMustBeContiguous(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
|
|
$body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 3, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('step_number', $body['errors'][0]['field']);
|
|
}
|
|
|
|
public function testStaffIsRequired(): void
|
|
{
|
|
[$user, , $service] = $this->clinicWithService();
|
|
|
|
$body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('staff_uuids', $body['errors'][0]['field']);
|
|
}
|
|
|
|
/** پرسنل با uuid از خودِ درخواست میآید و TenantFilter پوششش نمیدهد. */
|
|
public function testStaffFromAnotherEnvironmentIsRejected(): void
|
|
{
|
|
[$user, , $service] = $this->clinicWithService();
|
|
[, $otherClinic] = $this->clinicWithService();
|
|
$foreignStaff = $this->staffFor($otherClinic, 'اپراتور بیگانه');
|
|
|
|
$this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [$foreignStaff->getUuid()],
|
|
'steps' => [
|
|
['step_number' => 1, 'offset_days' => 0],
|
|
['step_number' => 2, 'offset_days' => 30],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(404, $this->responseCode());
|
|
}
|
|
|
|
public function testServiceFromAnotherEnvironmentIsNotFound(): void
|
|
{
|
|
[$user] = $this->clinicWithService();
|
|
[, , $otherService] = $this->clinicWithService();
|
|
|
|
$this->authJson('GET', '/api/v1/service-item/' . $otherService->getUuid() . '/treatment-protocol', $user);
|
|
|
|
self::assertSame(404, $this->responseCode());
|
|
}
|
|
|
|
public function testMaxStepsIsEnforced(): void
|
|
{
|
|
[$user, $clinic, $service] = $this->clinicWithService();
|
|
$staff = $this->staffFor($clinic, 'اپراتور یک');
|
|
|
|
$steps = [['step_number' => 1, 'offset_days' => 0]];
|
|
for ($i = 2; $i <= TreatmentProtocol::MAX_STEPS + 1; $i++) {
|
|
$steps[] = ['step_number' => $i, 'offset_days' => 30];
|
|
}
|
|
|
|
$body = $this->authJson('PUT', '/api/v1/service-item/' . $service->getUuid() . '/treatment-protocol', $user, [
|
|
'staff_uuids' => [$staff->getUuid()],
|
|
'steps' => $steps,
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('steps', $body['errors'][0]['field']);
|
|
}
|
|
}
|