feat(treatment): expose treatment cases, the unbooked queue and slot suggestions
Booking the next session stays a decision, not an automation: the system offers free slots and the secretary picks one with the patient in front of them. Booking automatically would fill the worst slot in the calendar — the one nobody wanted — and produce a no-show. A session whose due date has passed with nobody booking it surfaces in an explicit queue instead of waiting silently for the patient to call. Suggestions default to the resource the previous session ran on, since continuing a course on the same device is both clinically steadier and one less choice to make; with no previous booking the caller must name a resource rather than get an empty list. Slot maths is reused from ResourceBookingSlotService; this only decides which resource, from which day, and how far ahead to look. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Service\AddressResolver;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentCaseRepository;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
use App\Treatment\Service\NextSessionSlotFinder;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Treatment')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentCaseController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCaseRepository $cases,
|
||||
private readonly TreatmentSessionRepository $sessions,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly NextSessionSlotFinder $slotFinder,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly AddressResolver $branches,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
$status = $request->query->get('status');
|
||||
$status = is_string($status) && $status !== '' ? $status : null;
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (TreatmentCase $c): array => $c->toArray(),
|
||||
$this->cases->findForTenant($entityType, $entityId, $status),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->requireCase($user, $uuid)->toArray(withSessions: true));
|
||||
}
|
||||
|
||||
/**
|
||||
* صفِ «جلسات بدون نوبت» — کاری که منشی باید انجام دهد، نه رفتاری که خودکار اتفاق بیفتد.
|
||||
*
|
||||
* جلسهای که سررسیدش رسیده و کسی رزروش نکرده اینجا دیده میشود؛ بدون این، جلسهٔ
|
||||
* فراموششده در سکوت میماند تا بیمار خودش زنگ بزند.
|
||||
*/
|
||||
#[Route('/api/v1/treatment-sessions/unbooked', name: 'treatment_sessions_unbooked', methods: ['GET'])]
|
||||
public function unbooked(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
$withinDays = (int) $request->query->get('within_days', 7);
|
||||
$until = time() + max(0, min($withinDays, 90)) * 86400;
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (TreatmentSession $s): array => $s->toArray() + [
|
||||
'case_uuid' => $s->getTreatmentCase()->getUuid(),
|
||||
'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(),
|
||||
],
|
||||
$this->sessions->findUnbookedDue($entityType, $entityId, $until),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* اسلاتهای پیشنهادی برای این جلسه.
|
||||
*
|
||||
* پیشنهاد است، نه رزرو: منشی با بیمار هماهنگ میکند و بعد از مسیر عادی ثبت نوبت
|
||||
* یکی را میگیرد.
|
||||
*/
|
||||
#[Route('/api/v1/treatment-session/{uuid}/slot-suggestions', name: 'treatment_session_slots', methods: ['GET'])]
|
||||
public function slotSuggestions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$session = $this->requireSession($user, $uuid);
|
||||
|
||||
$resourceUuid = $request->query->get('resource_uuid');
|
||||
$resource = is_string($resourceUuid) && $resourceUuid !== ''
|
||||
? $this->resources->findByUuid($resourceUuid)
|
||||
: $this->slotFinder->preferredResource($session->getTreatmentCase());
|
||||
|
||||
if ($resource === null) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_VALIDATION_002,
|
||||
'منبعی برای پیشنهاد وقت مشخص نیست؛ resource_uuid بفرستید',
|
||||
422,
|
||||
'resource_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if (!$this->ownership->belongsToPair($entityType, $entityId, $resource)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404, 'resource_uuid');
|
||||
}
|
||||
|
||||
$days = (int) $request->query->get('days', NextSessionSlotFinder::DEFAULT_HORIZON_DAYS);
|
||||
|
||||
return $this->success($this->slotFinder->suggest($session, $resource, $days) + [
|
||||
'session' => $session->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function requireCase(User $user, string $uuid): TreatmentCase
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
$case = $this->cases->findByUuid($uuid);
|
||||
|
||||
if (!$this->ownership->belongsToPair($entityType, $entityId, $case)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پروندهٔ درمان یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $case;
|
||||
}
|
||||
|
||||
private function requireSession(User $user, string $uuid): TreatmentSession
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
$session = $this->sessions->findByUuid($uuid);
|
||||
|
||||
if (!$this->ownership->belongsToPair($entityType, $entityId, $session)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'جلسهٔ درمان یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Service;
|
||||
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Service\ResourceBookingSlotService;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
|
||||
/**
|
||||
* اسلاتهای پیشنهادی برای جلسهٔ بعد.
|
||||
*
|
||||
* سیستم پیشنهاد میدهد و منشی انتخاب میکند — رزرو خودکار نمیکنیم: سیستم نمیداند
|
||||
* بیمار پنجشنبهها سر کار است، و «اولین وقت آزاد» معمولاً بدترین وقت است چون کسی
|
||||
* نخواستهاش. نتیجهاش نوبتی میشد که بیمار نمیآید.
|
||||
*
|
||||
* محاسبهٔ خودِ اسلاتها از {@see ResourceBookingSlotService} میآید؛ اینجا فقط تصمیم
|
||||
* گرفته میشود «از کدام روز، روی کدام منبع، و چند روز جلوتر».
|
||||
*/
|
||||
final class NextSessionSlotFinder
|
||||
{
|
||||
public const DEFAULT_HORIZON_DAYS = 14;
|
||||
public const MAX_HORIZON_DAYS = 60;
|
||||
|
||||
public function __construct(private readonly ResourceBookingSlotService $slots) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* resource_uuid: string,
|
||||
* from: int,
|
||||
* days: list<array{date: string, slots: list<array{start:int, end:int, start_time:string, end_time:string}>}>
|
||||
* }
|
||||
*/
|
||||
public function suggest(TreatmentSession $session, ClinicResource $resource, int $days): array
|
||||
{
|
||||
$days = max(1, min($days, self::MAX_HORIZON_DAYS));
|
||||
|
||||
// سررسید گذشته باشد یعنی بیمار دیر کرده؛ جستوجو از امروز شروع میشود نه از
|
||||
// تاریخی که رد شده.
|
||||
$from = max($session->getDueAt() ?? time(), time());
|
||||
$minutes = $this->durationFor($session->getTreatmentCase(), $resource);
|
||||
|
||||
$out = [];
|
||||
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = date('Y-m-d', $from + $i * 86400);
|
||||
$slots = $this->slots->startTimes($resource, $date, $minutes);
|
||||
|
||||
if ($slots !== []) {
|
||||
$out[] = ['date' => $date, 'slots' => $slots];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'resource_uuid' => $resource->getUuid(),
|
||||
'from' => $from,
|
||||
'days' => $out,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* منبعِ پیشفرضِ جلسهٔ بعد: همان منبعی که جلسهٔ قبلی روی آن انجام شد.
|
||||
*
|
||||
* ادامهٔ دوره روی همان دستگاه، هم نتیجهٔ درمانی یکنواختتری میدهد هم انتخاب را از
|
||||
* دوش منشی برمیدارد. `null` یعنی هنوز هیچ جلسهای نوبت نگرفته و باید صریح انتخاب شود.
|
||||
*/
|
||||
public function preferredResource(TreatmentCase $case): ?ClinicResource
|
||||
{
|
||||
$sessions = $case->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $b->getSessionNumber() <=> $a->getSessionNumber());
|
||||
|
||||
foreach ($sessions as $session) {
|
||||
$resource = $session->getAppointment()?->getResource();
|
||||
|
||||
if ($resource !== null) {
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** مدت روی همان منبع حل میشود، نه از پیشفرض خام سرویس. */
|
||||
private function durationFor(TreatmentCase $case, ClinicResource $resource): int
|
||||
{
|
||||
$service = $case->getServiceItem();
|
||||
|
||||
try {
|
||||
return $this->slots->resolveDuration($resource, [$service->getUuid()])['minutes'];
|
||||
} catch (\Throwable) {
|
||||
// منبعی که این سرویس را ارائه نمیدهد هنوز میتواند اسلات نشان دهد؛ مدت
|
||||
// پیشفرض سرویس بهتر از هیچ پیشنهادی است.
|
||||
return $service->getDurationMinutes() ?? 30;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Treatment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
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\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentCaseArea;
|
||||
use App\Treatment\Entity\TreatmentProtocol;
|
||||
use App\Treatment\Entity\TreatmentProtocolStaff;
|
||||
use App\Treatment\Entity\TreatmentProtocolStep;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
|
||||
class TreatmentCaseEndpointsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{user: \App\Auth\Entity\User, clinic: Clinic, case: TreatmentCase, resource: ClinicResource} */
|
||||
private function scenario(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک پرونده');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر ناظر');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName('شعبهٔ مرکزی');
|
||||
$this->em->persist($address);
|
||||
|
||||
$type = new ResourceType('clinic', (int) $clinic->getId(), 'laser_' . bin2hex(random_bytes(3)), 'لیزر');
|
||||
$this->em->persist($type);
|
||||
$this->em->flush();
|
||||
|
||||
$resource = new ClinicResource($address, $type, 'دستگاه لیزر');
|
||||
$resource->setSupervisor($doctor);
|
||||
$this->em->persist($resource);
|
||||
|
||||
$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)->setDurationMinutes(30);
|
||||
$this->em->persist($service);
|
||||
|
||||
$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, 30),
|
||||
]);
|
||||
$protocol->replaceAllowedStaff([new TreatmentProtocolStaff($protocol, $staff)]);
|
||||
|
||||
$record = new PatientRecord('clinic', (int) $clinic->getId(), $this->createUser(), 'clinic', (int) $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$case = new TreatmentCase('clinic', (int) $clinic->getId(), $record, $service, $protocol);
|
||||
$case->addArea(new TreatmentCaseArea($case, $category, 0));
|
||||
$first = new TreatmentSession($case, 1);
|
||||
$second = new TreatmentSession($case, 2);
|
||||
$case->addSession($first);
|
||||
$case->addSession($second);
|
||||
$this->em->persist($case);
|
||||
$this->em->flush();
|
||||
|
||||
return ['user' => $user, 'clinic' => $clinic, 'case' => $case, 'resource' => $resource, 'doctor' => $doctor];
|
||||
}
|
||||
|
||||
public function testListReturnsCasesOfTheCurrentTenantOnly(): void
|
||||
{
|
||||
$mine = $this->scenario();
|
||||
$other = $this->scenario();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/treatment-cases', $mine['user']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$uuids = array_column($body['data'], 'uuid');
|
||||
self::assertContains($mine['case']->getUuid(), $uuids);
|
||||
self::assertNotContains($other['case']->getUuid(), $uuids);
|
||||
}
|
||||
|
||||
public function testShowReturnsSessionsAndAreas(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/treatment-case/' . $s['case']->getUuid(), $s['user']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(2, $body['data']['total_sessions']);
|
||||
self::assertSame(0, $body['data']['completed_sessions']);
|
||||
self::assertCount(2, $body['data']['sessions']);
|
||||
self::assertSame(['دست'], array_column($body['data']['areas'], 'name'));
|
||||
}
|
||||
|
||||
public function testACaseFromAnotherTenantIsNotFound(): void
|
||||
{
|
||||
$mine = $this->scenario();
|
||||
$other = $this->scenario();
|
||||
|
||||
$this->authJson('GET', '/api/v1/treatment-case/' . $other['case']->getUuid(), $mine['user']);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** جلسهای که سررسیدش رسیده و رزرو نشده باید در صف دیده شود. */
|
||||
public function testUnbookedQueueListsDueSessions(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$sessions = $s['case']->getSessions()->toArray();
|
||||
$sessions[0]->setDueAt(time() - 3600);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/treatment-sessions/unbooked', $s['user']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$uuids = array_column($body['data'], 'uuid');
|
||||
self::assertContains($sessions[0]->getUuid(), $uuids);
|
||||
// جلسهٔ دوم هنوز سررسید ندارد — لنگرش جلسهٔ اولِ انجامنشده است.
|
||||
self::assertNotContains($sessions[1]->getUuid(), $uuids);
|
||||
}
|
||||
|
||||
public function testUnbookedQueueSkipsSessionsThatAlreadyHaveAnAppointment(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$sessions = $s['case']->getSessions()->toArray();
|
||||
|
||||
$appointment = $this->newAppointment(
|
||||
$s['doctor'],
|
||||
$this->createUser(['ROLE_USER']),
|
||||
time() + 3600,
|
||||
time() + 5400,
|
||||
$s['clinic'],
|
||||
);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$sessions[0]->setDueAt(time() - 3600);
|
||||
$sessions[0]->attachAppointment($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/treatment-sessions/unbooked', $s['user']);
|
||||
|
||||
self::assertNotContains($sessions[0]->getUuid(), array_column($body['data'], 'uuid'));
|
||||
}
|
||||
|
||||
/** بدون منبع پیشفرض و بدون resource_uuid، باید صریح خطا بدهد نه فهرست خالی. */
|
||||
public function testSlotSuggestionsWithoutAResourceIsRejected(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$session = $s['case']->getSessions()->first();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/treatment-session/' . $session->getUuid() . '/slot-suggestions', $s['user']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('resource_uuid', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testSlotSuggestionsAcceptAnExplicitResource(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$session = $s['case']->getSessions()->first();
|
||||
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
'/api/v1/treatment-session/' . $session->getUuid() . '/slot-suggestions?resource_uuid=' . $s['resource']->getUuid(),
|
||||
$s['user'],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame($s['resource']->getUuid(), $body['data']['resource_uuid']);
|
||||
self::assertArrayHasKey('days', $body['data']);
|
||||
self::assertSame($session->getUuid(), $body['data']['session']['uuid']);
|
||||
}
|
||||
|
||||
public function testAResourceFromAnotherTenantIsNotFound(): void
|
||||
{
|
||||
$mine = $this->scenario();
|
||||
$other = $this->scenario();
|
||||
$session = $mine['case']->getSessions()->first();
|
||||
|
||||
$this->authJson(
|
||||
'GET',
|
||||
'/api/v1/treatment-session/' . $session->getUuid() . '/slot-suggestions?resource_uuid=' . $other['resource']->getUuid(),
|
||||
$mine['user'],
|
||||
);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user