Two holes in how a course's later appointments were made.
The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)
POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.
Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.
The system still never books the next appointment by itself — it only suggests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
10 KiB
PHP
258 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Treatment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Service\AppointmentConfirmationService;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\ClinicService\Entity\CatalogCategory;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\PracticeDomain\Entity\PracticeDomain;
|
|
use App\Staff\Entity\ClinicStaff;
|
|
use App\Tests\ApiTestCase;
|
|
use App\Treatment\Entity\TreatmentCase;
|
|
use App\Treatment\Entity\TreatmentProtocol;
|
|
use App\Treatment\Entity\TreatmentProtocolStaff;
|
|
use App\Treatment\Entity\TreatmentProtocolStep;
|
|
use App\Treatment\Entity\TreatmentSession;
|
|
|
|
/**
|
|
* تأیید نوبتِ سرویسِ پروتکلدار باید پروندهٔ درمان باز کند — بدون اینکه مسیر رزرو
|
|
* چیزی دربارهٔ لیزر بداند.
|
|
*/
|
|
class OpenCaseOnConfirmTest extends ApiTestCase
|
|
{
|
|
private function confirmation(): AppointmentConfirmationService
|
|
{
|
|
return static::getContainer()->get(AppointmentConfirmationService::class);
|
|
}
|
|
|
|
private function scenario(bool $withProtocol, ?string $domainCode = 'beauty', bool $confirm = true): array
|
|
{
|
|
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون');
|
|
$this->em->persist($doctor);
|
|
|
|
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
|
|
$clinic->setName('کلینیک دورهٔ درمان');
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
if ($domainCode !== null) {
|
|
$domain = $this->em->getRepository(PracticeDomain::class)->findOneBy(['code' => $domainCode])
|
|
?? new PracticeDomain($domainCode, 'حوزهٔ آزمون');
|
|
$this->em->persist($domain);
|
|
$this->em->flush();
|
|
$clinic->setPracticeDomain($domain);
|
|
}
|
|
|
|
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
|
|
$this->em->persist($section);
|
|
|
|
$category = new CatalogCategory('clinic', (int) $clinic->getId(), 'دست');
|
|
$this->em->persist($category);
|
|
|
|
$service = new ServiceItem($section, 'لیزر دست', 5_000_000);
|
|
$service->setCatalogCategory($category);
|
|
$this->em->persist($service);
|
|
$this->em->flush();
|
|
|
|
if ($withProtocol) {
|
|
$staff = new ClinicStaff('clinic', (int) $clinic->getId(), 'اپراتور');
|
|
$this->em->persist($staff);
|
|
|
|
$protocol = new TreatmentProtocol($service);
|
|
$this->em->persist($protocol);
|
|
$protocol->replaceSteps([
|
|
new TreatmentProtocolStep($protocol, 1, 0),
|
|
new TreatmentProtocolStep($protocol, 2, 15),
|
|
new TreatmentProtocolStep($protocol, 3, 30),
|
|
]);
|
|
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
|
$this->em->flush();
|
|
}
|
|
|
|
$appointment = $this->newAppointment(
|
|
$doctor,
|
|
$this->createUser(['ROLE_USER']),
|
|
1_790_000_000,
|
|
1_790_001_800,
|
|
$clinic,
|
|
);
|
|
$appointment->addServiceItem($service);
|
|
if ($confirm) {
|
|
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
}
|
|
$this->em->persist($appointment);
|
|
$this->em->flush();
|
|
|
|
return [$appointment, $service, $clinic];
|
|
}
|
|
|
|
/** @return TreatmentCase[] */
|
|
private function casesFor(ServiceItem $service): array
|
|
{
|
|
return $this->em->getRepository(TreatmentCase::class)->findBy(['serviceItem' => $service]);
|
|
}
|
|
|
|
public function testConfirmingAProtocolServiceOpensACase(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: true);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
$cases = $this->casesFor($service);
|
|
self::assertCount(1, $cases);
|
|
self::assertSame(3, $cases[0]->getTotalSessions());
|
|
self::assertCount(3, $cases[0]->getSessions());
|
|
self::assertSame(['دست'], array_map(
|
|
static fn ($a) => $a->getName(),
|
|
$cases[0]->getAreas()->toArray(),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* مسیر واقعیِ منشی: قطعیکردن از پنل با مودال پرداخت.
|
|
*
|
|
* این مسیر جدا از onConfirmed است و مدتی پروندهٔ درمان را نمیساخت — نوبت قطعی
|
|
* میشد، پول ثبت میشد، و پنل پرسنل خالی میماند چون هیچ جلسهای وجود نداشت.
|
|
*/
|
|
public function testConfirmingFromThePanelWithPaymentsOpensACase(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: true, confirm: false);
|
|
|
|
$this->confirmation()->confirmWithPayments(
|
|
$appointment,
|
|
$appointment->getVersion(),
|
|
[['method' => 'cash', 'amount_rials' => 5_000_000]],
|
|
$this->createUser(['ROLE_CLINIC']),
|
|
);
|
|
|
|
$cases = $this->casesFor($service);
|
|
self::assertCount(1, $cases);
|
|
self::assertCount(3, $cases[0]->getSessions());
|
|
|
|
$sessions = $cases[0]->getSessions()->toArray();
|
|
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
|
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
|
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
|
|
}
|
|
|
|
/** سرویس بدون پروتکل همان نوبت تکجلسهای است — نباید پروندهای ساخته شود. */
|
|
public function testConfirmingAPlainServiceOpensNoCase(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: false);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
self::assertSame([], $this->casesFor($service));
|
|
}
|
|
|
|
/** کلینیکِ بدون حوزهٔ فعالیت هم باید دورهاش کار کند — نال یعنی رفتار پیشفرض. */
|
|
public function testCaseOpensEvenWithoutAPracticeDomain(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: true, domainCode: null);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
self::assertCount(1, $this->casesFor($service));
|
|
}
|
|
|
|
public function testFirstSessionIsAttachedToTheAppointment(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: true);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
|
|
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
|
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
|
|
|
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
|
|
self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
|
|
self::assertSame($appointment->getSlotStart(), $sessions[0]->getDueAt());
|
|
|
|
// جلسات بعدی نه نوبت دارند نه سررسید قطعی — سررسیدشان از جلسهٔ قبل میآید.
|
|
self::assertNull($sessions[1]->getAppointment());
|
|
self::assertNull($sessions[1]->getDueAt());
|
|
}
|
|
|
|
/**
|
|
* نوبتِ لغوشده باید جلسه را آزاد کند.
|
|
*
|
|
* بدون این، جلسه تا ابد `booked` میماند و چون `findNextUnbooked` شرطش «نوبت
|
|
* ندارد» است، هرگز به صفِ «جلسات بدون نوبت» برنمیگردد.
|
|
*/
|
|
public function testCancellingAnAppointmentReleasesItsSession(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: true);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
|
|
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
|
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
|
|
|
self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
|
|
|
|
static::getContainer()->get(\App\Treatment\Service\SessionBookingLink::class)->release($appointment);
|
|
|
|
self::assertNull($sessions[0]->getAppointment());
|
|
self::assertSame(TreatmentSession::STATUS_PLANNED, $sessions[0]->getStatus());
|
|
}
|
|
|
|
/** جلسهٔ انجامشده سابقه است؛ لغوِ بعدیِ نوبت نباید آن را باز کند. */
|
|
public function testReleasingDoesNotTouchAFinishedSession(): void
|
|
{
|
|
[$appointment, $service] = $this->scenario(withProtocol: true);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
|
|
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
|
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
|
|
|
$sessions[0]->start();
|
|
$sessions[0]->finish();
|
|
$this->em->flush();
|
|
|
|
static::getContainer()->get(\App\Treatment\Service\SessionBookingLink::class)->release($appointment);
|
|
|
|
self::assertNotNull($sessions[0]->getAppointment());
|
|
self::assertSame(TreatmentSession::STATUS_DONE, $sessions[0]->getStatus());
|
|
}
|
|
|
|
/** بیمار وسط دوره نوبت دیگری میگیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */
|
|
public function testASecondAppointmentDoesNotOpenASecondCase(): void
|
|
{
|
|
[$appointment, $service, $clinic] = $this->scenario(withProtocol: true);
|
|
|
|
$this->confirmation()->onConfirmed($appointment);
|
|
|
|
$second = $this->newAppointment(
|
|
$appointment->getDoctor(),
|
|
$appointment->getUser(),
|
|
1_791_000_000,
|
|
1_791_001_800,
|
|
$clinic,
|
|
);
|
|
$second->addServiceItem($service);
|
|
$second->transitionTo(Appointment::STATUS_CONFIRMED);
|
|
$this->em->persist($second);
|
|
$this->em->flush();
|
|
|
|
$this->confirmation()->onConfirmed($second);
|
|
|
|
$cases = $this->casesFor($service);
|
|
self::assertCount(1, $cases);
|
|
|
|
$sessions = $cases[0]->getSessions()->toArray();
|
|
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
|
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
|
|
|
self::assertSame($second->getId(), $sessions[1]->getAppointment()?->getId());
|
|
}
|
|
}
|