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;
}
}