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:
hamed
2026-08-06 18:03:16 +03:30
co-authored by Claude Opus 5
parent 9a95bc59d4
commit 1cdd62979f
3 changed files with 445 additions and 0 deletions
@@ -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;
}
}
}