feat(treatment): run a session from the staff panel, area by area
The operator opens the session, treats each body area on its own device and records what that device was set to. Readings are validated against the resource type's field schema, so a laser form and an RF form each enforce their own rules without this code naming either. Finishing is allowed with areas still open — the operator is standing in front of a patient and must not be trapped by the software — but the count comes back so the panel can warn. Session state mirrors onto the appointment (salon, then completed) while its slot times are never rewritten: those are the reservation's promise and the input to occupancy, whereas how long it actually took belongs to the session. Overwriting them would destroy the comparison between the two. Who performed it is recorded on the session rather than inferred from the appointment's planned staff: when a colleague covers a sick operator, the medical record must say who actually held the device. Endpoints live under /api/v1/dashboard/staff because StaffRouteGuardSubscriber closes everything else to staff-only users. Opening a second door through its allowlist would put the access boundary in two places. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Treatment\Entity\SessionAreaRecord;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\SessionAreaRecordRepository;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
use App\Treatment\Service\SessionExecutor;
|
||||
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;
|
||||
|
||||
/**
|
||||
* پنل پرسنل: انجام جلسه.
|
||||
*
|
||||
* همهٔ مسیرها زیر `/api/v1/dashboard/staff` مینشینند چون
|
||||
* {@see \App\Staff\Security\StaffRouteGuardSubscriber} کاربرِ فقط-پرسنل را بیرون از
|
||||
* همان پیشوند میبندد. باز کردن راهِ تازه با افزودن به allowlist یعنی مرزِ دسترسی در
|
||||
* دو جا تعریف شود؛ اینطور یک جا میماند.
|
||||
*
|
||||
* نقش بهتنهایی کافی نیست — ردیف فعالِ پرسنل در محیط جاری هم باید باشد، چون توکن تا
|
||||
* انقضا معتبر میماند و غیرفعالشدنِ پرسنل باید همان لحظه دسترسی را ببندد. همان
|
||||
* قاعدهای که `DashboardController::staff` دارد.
|
||||
*/
|
||||
#[OA\Tag(name: 'Treatment')]
|
||||
#[IsGranted('ROLE_STAFF')]
|
||||
class SessionExecutionController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentSessionRepository $sessions,
|
||||
private readonly SessionAreaRecordRepository $areaRecords,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly SessionExecutor $executor,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
) {}
|
||||
|
||||
/** جلسات امروزِ همین پرسنل — «جلسات امروز من». */
|
||||
#[Route('/api/v1/dashboard/staff/treatment-sessions', name: 'staff_treatment_sessions', methods: ['GET'])]
|
||||
public function today(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$staff = $this->requireStaff($user);
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (TreatmentSession $s): array => $s->toArray(withAreas: true) + [
|
||||
'case_uuid' => $s->getTreatmentCase()->getUuid(),
|
||||
'service_name' => $s->getTreatmentCase()->getServiceItem()->getName(),
|
||||
],
|
||||
$this->sessions->findTodayForStaff(
|
||||
$staff,
|
||||
strtotime('today midnight'),
|
||||
strtotime('tomorrow midnight') - 1,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/dashboard/staff/treatment-session/{uuid}', name: 'staff_treatment_session_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$session = $this->requireSession($user, $uuid);
|
||||
|
||||
return $this->success($session->toArray(withAreas: true) + [
|
||||
'case' => $session->getTreatmentCase()->toArray(),
|
||||
// فرمِ هر ناحیه از نوع منبعش میآید؛ پنل نباید فیلدها را حدس بزند.
|
||||
'forms' => $this->formsFor($session),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/dashboard/staff/treatment-session/{uuid}/start', name: 'staff_treatment_session_start', methods: ['POST'])]
|
||||
public function start(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$staff = $this->requireStaff($user);
|
||||
$session = $this->requireSession($user, $uuid);
|
||||
|
||||
return $this->success($this->executor->startSession($session, $staff)->toArray(withAreas: true));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/dashboard/staff/treatment-session/{uuid}/finish', name: 'staff_treatment_session_finish', methods: ['POST'])]
|
||||
public function finish(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$session = $this->requireSession($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$note = is_string($data['note'] ?? null) ? trim($data['note']) : null;
|
||||
|
||||
$result = $this->executor->finishSession($session, $note !== '' ? $note : null);
|
||||
|
||||
return $this->success($result['session']->toArray(withAreas: true) + [
|
||||
// بستن با ناحیهٔ ناتمام مجاز است؛ پنل با همین عدد هشدار میدهد.
|
||||
'unsettled_areas' => $result['unsettled_areas'],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/dashboard/staff/session-area/{uuid}/start', name: 'staff_session_area_start', methods: ['POST'])]
|
||||
public function startArea(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->requireAreaRecord($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
return $this->success(
|
||||
$this->executor->startArea($record, $this->resolveResource($user, $data))->toArray(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/dashboard/staff/session-area/{uuid}/complete', name: 'staff_session_area_complete', methods: ['POST'])]
|
||||
public function completeArea(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$record = $this->requireAreaRecord($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$note = is_string($data['note'] ?? null) ? trim($data['note']) : null;
|
||||
|
||||
return $this->success($this->executor->completeArea(
|
||||
$record,
|
||||
$this->resolveResource($user, $data),
|
||||
is_array($data['parameters'] ?? null) ? $data['parameters'] : null,
|
||||
$note !== '' ? $note : null,
|
||||
)->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/dashboard/staff/session-area/{uuid}/skip', name: 'staff_session_area_skip', methods: ['POST'])]
|
||||
public function skipArea(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->executor->skipArea($this->requireAreaRecord($user, $uuid))->toArray());
|
||||
}
|
||||
|
||||
/** @return array<string, list<array<string, mixed>>> uuid منبع => تعریف فیلدها */
|
||||
private function formsFor(TreatmentSession $session): array
|
||||
{
|
||||
$forms = [];
|
||||
|
||||
foreach ($session->getAreaRecords() as $record) {
|
||||
$resource = $record->getResource();
|
||||
|
||||
if ($resource !== null) {
|
||||
$forms[$resource->getUuid()] = $resource->getType()->getFieldSchema() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
return $forms;
|
||||
}
|
||||
|
||||
private function resolveResource(User $user, array $data): ?ClinicResource
|
||||
{
|
||||
$uuid = is_string($data['resource_uuid'] ?? null) ? trim($data['resource_uuid']) : '';
|
||||
|
||||
if ($uuid === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resource = $this->resources->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->pair($user);
|
||||
|
||||
if (!$this->ownership->belongsToPair($entityType, $entityId, $resource)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404, 'resource_uuid');
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
private function requireStaff(User $user): ClinicStaff
|
||||
{
|
||||
[$entityType, $entityId] = $this->pair($user);
|
||||
$staff = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
|
||||
|
||||
if ($staff === null) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
return $staff;
|
||||
}
|
||||
|
||||
private function requireSession(User $user, string $uuid): TreatmentSession
|
||||
{
|
||||
[$entityType, $entityId] = $this->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;
|
||||
}
|
||||
|
||||
private function requireAreaRecord(User $user, string $uuid): SessionAreaRecord
|
||||
{
|
||||
[$entityType, $entityId] = $this->pair($user);
|
||||
$record = $this->areaRecords->findByUuid($uuid);
|
||||
|
||||
if (!$this->ownership->belongsToPair($entityType, $entityId, $record)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'ناحیهٔ جلسه یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: int} */
|
||||
private function pair(User $user): array
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
|
||||
if (!$context->isResolved()) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
return $context->toEntityPair();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Service\FieldSchemaValidator;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Treatment\Entity\SessionAreaRecord;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Workflow\TreatmentWorkflowRegistry;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* اجرای یک جلسه در پنل پرسنل: شروع، ثبت هر ناحیه، و بستن جلسه.
|
||||
*
|
||||
* وضعیت نوبت همگام میشود ولی زمانش هرگز بازنویسی نمیشود: `slot_start` و `slot_end`
|
||||
* تعهدِ رزرو و ورودیِ محاسبهٔ اشغالاند، و «چقدر طول کشید» یک واقعیتِ جداست که در
|
||||
* `started_at`/`finished_at` مینشیند. بازنویسی گذشته یعنی مقایسهٔ پیشبینی با واقعیت
|
||||
* برای همیشه از بین میرود.
|
||||
*/
|
||||
final class SessionExecutor
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FieldSchemaValidator $fieldSchema,
|
||||
private readonly TreatmentWorkflowRegistry $workflows,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/** شروع جلسه: رکورد هر ناحیه ساخته میشود تا اپراتور فهرستش را ببیند. */
|
||||
public function startSession(TreatmentSession $session, ?ClinicStaff $performedBy = null): TreatmentSession
|
||||
{
|
||||
if ($session->getStatus() === TreatmentSession::STATUS_DONE) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این جلسه قبلاً تمام شده است', 422);
|
||||
}
|
||||
|
||||
if ($session->getStartedAt() === null) {
|
||||
$session->start();
|
||||
}
|
||||
|
||||
if ($performedBy !== null) {
|
||||
$session->setPerformedBy($performedBy);
|
||||
}
|
||||
|
||||
$this->ensureAreaRecords($session);
|
||||
$this->syncAppointment($session, Appointment::STATUS_SALON);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
public function startArea(SessionAreaRecord $record, ?ClinicResource $resource): SessionAreaRecord
|
||||
{
|
||||
if ($record->isSettled()) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422);
|
||||
}
|
||||
|
||||
$record->start($resource);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* بستن یک ناحیه با خواندههای دستگاه.
|
||||
*
|
||||
* مقادیر با `field_schema`ِ همان نوع منبع سنجیده میشوند، پس فرمِ لیزر و فرمِ RF
|
||||
* هرکدام قواعد خودشان را دارند بدون اینکه اینجا نامی از دستگاه بیاید.
|
||||
*/
|
||||
public function completeArea(
|
||||
SessionAreaRecord $record,
|
||||
?ClinicResource $resource,
|
||||
?array $parameters,
|
||||
?string $note,
|
||||
): SessionAreaRecord {
|
||||
if ($record->isSettled()) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422);
|
||||
}
|
||||
|
||||
$resource ??= $record->getResource();
|
||||
|
||||
if ($resource === null) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'دستگاه این ناحیه مشخص نیست', 422, 'resource_uuid');
|
||||
}
|
||||
|
||||
$clean = $this->fieldSchema->validateValues($resource->getType()->getFieldSchema(), $parameters);
|
||||
|
||||
$record->complete($resource, $clean, $note);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/** بیمار امروز فقط یک ناحیه میخواهد — بقیه صرفنظر میشوند، نه ناتمام رها. */
|
||||
public function skipArea(SessionAreaRecord $record): SessionAreaRecord
|
||||
{
|
||||
if ($record->isSettled()) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422);
|
||||
}
|
||||
|
||||
$record->skip();
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* بستن جلسه.
|
||||
*
|
||||
* ناحیهٔ ناتمام مانع نیست — اپراتور جلوی بیمار ایستاده و نباید در نرمافزار گیر
|
||||
* کند — ولی تعدادش برگردانده میشود تا پنل هشدار بدهد.
|
||||
*
|
||||
* @return array{session: TreatmentSession, unsettled_areas: int}
|
||||
*/
|
||||
public function finishSession(TreatmentSession $session, ?string $note): array
|
||||
{
|
||||
if ($session->getStatus() === TreatmentSession::STATUS_DONE) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این جلسه قبلاً تمام شده است', 422);
|
||||
}
|
||||
|
||||
$unsettled = 0;
|
||||
foreach ($session->getAreaRecords() as $record) {
|
||||
if (!$record->isSettled()) {
|
||||
$unsettled++;
|
||||
}
|
||||
}
|
||||
|
||||
$session->finish($note);
|
||||
$this->syncAppointment($session, Appointment::STATUS_COMPLETED);
|
||||
$this->em->flush();
|
||||
|
||||
// سررسید جلسهٔ بعد و بستن دوره کارِ workflow حوزهٔ فعالیت است، نه این سرویس.
|
||||
$domainCode = $session->getAppointment()?->getClinic()?->getPracticeDomain()?->getCode();
|
||||
$this->workflows->for($domainCode)->onSessionFinished($session);
|
||||
|
||||
return ['session' => $session, 'unsettled_areas' => $unsettled];
|
||||
}
|
||||
|
||||
/** رکورد هر ناحیهٔ پرونده، یک بار per جلسه. */
|
||||
private function ensureAreaRecords(TreatmentSession $session): void
|
||||
{
|
||||
$existing = [];
|
||||
foreach ($session->getAreaRecords() as $record) {
|
||||
$existing[(int) $record->getCaseArea()->getId()] = true;
|
||||
}
|
||||
|
||||
foreach ($session->getTreatmentCase()->getAreas() as $area) {
|
||||
if (!isset($existing[(int) $area->getId()])) {
|
||||
$session->addAreaRecord(new SessionAreaRecord($session, $area));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* وضعیت نوبت را دنبال وضعیت جلسه میبرد.
|
||||
*
|
||||
* گذارِ نامجاز جلسه را زمین نمیزند: اپراتور کارش را کرده و نباید بهخاطر وضعیتی
|
||||
* که منشی دستی عوض کرده خطای ۵۰۰ ببیند. لاگ میشود تا دیده شود.
|
||||
*/
|
||||
private function syncAppointment(TreatmentSession $session, string $status): void
|
||||
{
|
||||
$appointment = $session->getAppointment();
|
||||
|
||||
if ($appointment === null || $appointment->getStatus() === $status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$appointment->canTransitionTo($status)) {
|
||||
$this->logger->warning('Session status could not be mirrored onto its appointment', [
|
||||
'session_uuid' => $session->getUuid(),
|
||||
'from' => $appointment->getStatus(),
|
||||
'to' => $status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$appointment->transitionTo($status);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user