feat(course): treatment courses with protocol-driven session planning
Laser is six to eight sessions; the previous design only knew single appointments, which is the exception rather than the rule. - CourseProtocol per service: session count and three distinct spacings — min is the earliest that is clinically allowed, ideal is best, max is where the course starts losing its effect - Starting a course creates every session up front as `planned` and copies the protocol's numbers and per-session params, so changing the protocol tomorrow leaves a running course alone - Suggestions anchor on the last *completed* session, not the course start: when session 2 slips, session 3 moves with it - Slots are ranked by distance from ideal, not by earliest available — day 21 is worse than day 27 when 28 is the target - book-all is all-or-nothing inside one transaction, with a moving anchor and a 90-day horizon; sessions past the horizon stay planned and are reported, not treated as failures - The effective minimum is the stricter of the protocol and the task-09 spacing policy, so a clinic rule never fights the protocol - Cancelling one session returns only that session to planned; abandoning a course does not cancel its appointments, which stays an explicit decision One active course per (patient, service) via active_course_key, the same partial-uniqueness trick as Appointment::activeSlotKey. Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the patient record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Appointment\Booking\Entity\AppointmentHold;
|
||||
use App\Appointment\Booking\Entity\AppointmentSegment;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Package\Service\CreditLedgerService;
|
||||
use App\Course\Service\CourseSessionLinker;
|
||||
use App\Package\Service\PackageConsumptionService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
@@ -25,6 +26,7 @@ final class BookingService
|
||||
private readonly HoldService $holds,
|
||||
private readonly PackageConsumptionService $packages,
|
||||
private readonly CreditLedgerService $credits,
|
||||
private readonly CourseSessionLinker $courseSessions,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
@@ -107,6 +109,9 @@ final class BookingService
|
||||
// ردیف `consume` **حذف نمیشود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند.
|
||||
$this->credits->refund($appointment);
|
||||
|
||||
// جلسهٔ دوره به `planned` برمیگردد؛ بقیهٔ جلسات دستنخورده میمانند.
|
||||
$this->courseSessions->unlink($appointment);
|
||||
|
||||
return count($occupancies);
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,14 @@ class Appointment
|
||||
#[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)]
|
||||
private ?int $serviceBufferMinutes = null;
|
||||
|
||||
/**
|
||||
* پیوند به جلسهٔ دوره — عمداً دوطرفه است تا لیست نوبتها بدون JOIN بفهمد این نوبت
|
||||
* جزو یک دوره است. فقط `CourseSessionLinker` مینویسدش.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: \App\Course\Entity\CourseSession::class)]
|
||||
#[ORM\JoinColumn(name: 'course_session_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\Course\Entity\CourseSession $courseSession = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -286,6 +294,9 @@ class Appointment
|
||||
public function setAddressId(?int $v): self { $this->addressId = $v; return $this; }
|
||||
public function setClinic(?\App\Clinic\Entity\Clinic $v): self { $this->clinic = $v; return $this; }
|
||||
public function setPatientName(?string $v): self { $this->patientName = $v; return $this; }
|
||||
|
||||
public function getCourseSession(): ?\App\Course\Entity\CourseSession { return $this->courseSession; }
|
||||
public function setCourseSession(?\App\Course\Entity\CourseSession $v): self { $this->courseSession = $v; return $this; }
|
||||
public function setPatientMobile(?string $v): self { $this->patientMobile = $v; return $this; }
|
||||
public function setPatientNationalCode(?string $v): self { $this->patientNationalCode = $v; return $this; }
|
||||
public function setPatientGender(?string $v): self { $this->patientGender = $v; return $this; }
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseProtocolStep;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
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: 'Course')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class CourseProtocolController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseProtocolRepository $protocols,
|
||||
private readonly ServiceItemRepository $items,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/course-protocols', name: 'course_protocol_index', methods: ['GET'])]
|
||||
public function index(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
return $this->success(array_map(
|
||||
static fn (CourseProtocol $p): array => $p->toArray(),
|
||||
$this->protocols->findForPair($entityType, $entityId),
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/course-protocols', name: 'course_protocol_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ سرویس الزامی است', 422, 'service_uuid');
|
||||
}
|
||||
|
||||
$service = $this->requireItem($user, $data['service_uuid']);
|
||||
|
||||
if ($this->protocols->findForService($service) !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این سرویس از قبل پروتکل دارد', 422, 'service_uuid');
|
||||
}
|
||||
|
||||
try {
|
||||
$protocol = new CourseProtocol(
|
||||
$service,
|
||||
(int) ($data['session_count'] ?? 0),
|
||||
(int) ($data['min_days'] ?? 0),
|
||||
(int) ($data['ideal_days'] ?? 0),
|
||||
(int) ($data['max_days'] ?? 0),
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $this->explain($e), 422, 'session_count');
|
||||
}
|
||||
|
||||
$this->applyFlags($protocol, $data);
|
||||
$this->replaceSteps($protocol, $data['steps'] ?? null);
|
||||
|
||||
$this->protocols->save($protocol);
|
||||
|
||||
return $this->success($protocol->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->requireProtocol($user, $uuid)->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$protocol = $this->requireProtocol($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$protocol->setShape(
|
||||
(int) ($data['session_count'] ?? $protocol->getSessionCount()),
|
||||
(int) ($data['min_days'] ?? $protocol->getMinDays()),
|
||||
(int) ($data['ideal_days'] ?? $protocol->getIdealDays()),
|
||||
(int) ($data['max_days'] ?? $protocol->getMaxDays()),
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, $this->explain($e), 422, 'session_count');
|
||||
}
|
||||
|
||||
$this->applyFlags($protocol, $data);
|
||||
|
||||
if (array_key_exists('steps', $data)) {
|
||||
$this->replaceSteps($protocol, $data['steps']);
|
||||
}
|
||||
|
||||
$this->protocols->save($protocol);
|
||||
|
||||
return $this->success($protocol->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* حذف = غیرفعال کردن.
|
||||
*
|
||||
* دورههای در جریان به پروتکل ارجاع دارند؛ حذف واقعی یعنی پروندهٔ بیمار نتواند
|
||||
* بگوید از کجا آمده.
|
||||
*/
|
||||
#[Route('/api/v1/course-protocol/{uuid}', name: 'course_protocol_delete', methods: ['DELETE'])]
|
||||
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$protocol = $this->requireProtocol($user, $uuid)->setActive(false);
|
||||
$this->protocols->save($protocol);
|
||||
|
||||
return $this->success($protocol->toArray());
|
||||
}
|
||||
|
||||
private function explain(\InvalidArgumentException $e): string
|
||||
{
|
||||
return str_contains($e->getMessage(), 'two sessions')
|
||||
? 'دورهٔ کمتر از دو جلسه همان نوبت تکی است'
|
||||
: 'ترتیب فاصلهها باید حداقل ≤ ایدهآل ≤ حداکثر باشد';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function applyFlags(CourseProtocol $protocol, array $data): void
|
||||
{
|
||||
if (isset($data['prefer_same_resource'])) {
|
||||
$protocol->setPreferSameResource((bool) $data['prefer_same_resource']);
|
||||
}
|
||||
|
||||
if (isset($data['active'])) {
|
||||
$protocol->setActive((bool) $data['active']);
|
||||
}
|
||||
}
|
||||
|
||||
/** جایگزینی کامل — قرارداد `PUT` روی زیرمجموعه، همان الگوی ساعت کاری شعبه. */
|
||||
private function replaceSteps(CourseProtocol $protocol, mixed $steps): void
|
||||
{
|
||||
if (!is_array($steps)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($protocol->getSteps() as $existing) {
|
||||
$this->em->remove($existing);
|
||||
}
|
||||
|
||||
$protocol->getSteps()->clear();
|
||||
|
||||
foreach ($steps as $step) {
|
||||
if (!is_array($step) || !is_numeric($step['session_number'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$number = (int) $step['session_number'];
|
||||
|
||||
if ($number < 1 || $number > $protocol->getSessionCount()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('شمارهٔ جلسه باید بین ۱ و %d باشد', $protocol->getSessionCount()),
|
||||
422,
|
||||
'steps',
|
||||
);
|
||||
}
|
||||
|
||||
$this->em->persist(new CourseProtocolStep(
|
||||
$protocol,
|
||||
$number,
|
||||
is_array($step['params'] ?? null) ? $step['params'] : [],
|
||||
is_numeric($step['override_duration_minutes'] ?? null) ? (int) $step['override_duration_minutes'] : null,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function requireItem(User $user, string $uuid): ServiceItem
|
||||
{
|
||||
$item = $this->items->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($item === null
|
||||
|| $item->getSection()->getEntityType() !== $entityType
|
||||
|| $item->getSection()->getEntityId() !== $entityId
|
||||
) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function requireProtocol(User $user, string $uuid): CourseProtocol
|
||||
{
|
||||
$protocol = $this->protocols->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($protocol === null || !$this->ownership->belongsToPair($entityType, $entityId, $protocol)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پروتکل یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $protocol;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Course\Service\CourseBooker;
|
||||
use App\Course\Service\CourseProgressCalculator;
|
||||
use App\Course\Service\CourseScheduler;
|
||||
use App\Course\Service\CourseStarter;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Package\Repository\PatientPackageRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
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: 'Course')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentCourseController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCourseRepository $courses,
|
||||
private readonly CourseProtocolRepository $protocols,
|
||||
private readonly PatientRecordRepository $patients,
|
||||
private readonly PatientPackageRepository $patientPackages,
|
||||
private readonly DoctorRepository $doctors,
|
||||
private readonly CourseStarter $starter,
|
||||
private readonly CourseScheduler $scheduler,
|
||||
private readonly CourseBooker $booker,
|
||||
private readonly CourseProgressCalculator $progress,
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/treatment-course', name: 'treatment_course_create', methods: ['POST'])]
|
||||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['patient_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ بیمار الزامی است', 422, 'patient_uuid');
|
||||
}
|
||||
|
||||
if (!is_string($data['protocol_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شناسهٔ پروتکل الزامی است', 422, 'protocol_uuid');
|
||||
}
|
||||
|
||||
$patient = $this->requirePatient($user, $data['patient_uuid']);
|
||||
$protocol = $this->protocols->findByUuid($data['protocol_uuid']);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($protocol === null || !$this->ownership->belongsToPair($entityType, $entityId, $protocol)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پروتکل یافت نشد', 404);
|
||||
}
|
||||
|
||||
$package = null;
|
||||
|
||||
if (is_string($data['patient_package_uuid'] ?? null)) {
|
||||
$package = $this->patientPackages->findByUuid($data['patient_package_uuid']);
|
||||
|
||||
if ($package === null || !$this->ownership->belongsToPair($entityType, $entityId, $package)) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج بیمار یافت نشد', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$course = $this->starter->start($patient, $protocol, $package);
|
||||
|
||||
return $this->success($this->detail($course), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/treatment-course/{uuid}', name: 'treatment_course_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->detail($this->requireCourse($user, $uuid)));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/courses', name: 'patient_course_index', methods: ['GET'])]
|
||||
public function forPatient(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$patient = $this->requirePatient($user, $uuid);
|
||||
|
||||
return $this->success(array_map(
|
||||
fn (TreatmentCourse $c): array => $c->toArray() + ['progress' => $this->progress->progressOf($c)],
|
||||
$this->courses->findForPatient($patient),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* پیشنهاد تاریخ جلسهٔ بعدی — بازهٔ مجاز، تاریخ ایدهآل و چند وقت نزدیک به آن.
|
||||
*/
|
||||
#[Route('/api/v1/treatment-course/{uuid}/next-slot-suggestion', name: 'treatment_course_next_slot', methods: ['GET'])]
|
||||
public function nextSlot(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$course = $this->requireCourse($user, $uuid);
|
||||
$branch = $request->query->get('branch_uuid');
|
||||
|
||||
if (!is_string($branch)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid');
|
||||
}
|
||||
|
||||
$address = $this->branches->resolve($user, $branch);
|
||||
|
||||
return $this->success($this->scheduler->suggestNext($course, $address));
|
||||
}
|
||||
|
||||
/** رزرو همهٔ جلسات باقیمانده — همه یا هیچ. */
|
||||
#[Route('/api/v1/treatment-course/{uuid}/book-all', name: 'treatment_course_book_all', methods: ['POST'])]
|
||||
public function bookAll(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$course = $this->requireCourse($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['branch_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid');
|
||||
}
|
||||
|
||||
if (!is_string($data['doctor_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد doctor_uuid الزامی است', 422, 'doctor_uuid');
|
||||
}
|
||||
|
||||
$address = $this->branches->resolve($user, $data['branch_uuid']);
|
||||
$doctor = $this->doctors->findOneBy(['uuid' => $data['doctor_uuid']]);
|
||||
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$result = $this->booker->bookAll($course, $address, $doctor, $user);
|
||||
|
||||
return $this->success($result + ['course' => $this->detail($course)]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/treatment-course/{uuid}/abandon', name: 'treatment_course_abandon', methods: ['POST'])]
|
||||
public function abandon(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$course = $this->requireCourse($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data) || !is_string($data['reason'] ?? null) || trim($data['reason']) === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رهاکردن دوره الزامی است', 422, 'reason');
|
||||
}
|
||||
|
||||
if (!$course->isActive()) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این دوره فعال نیست', 422);
|
||||
}
|
||||
|
||||
$course->abandon(trim($data['reason']));
|
||||
$this->courses->save($course);
|
||||
|
||||
return $this->success($this->detail($course));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function detail(TreatmentCourse $course): array
|
||||
{
|
||||
$sessions = $course->getSessions()->toArray();
|
||||
|
||||
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
return $course->toArray() + [
|
||||
'progress' => $this->progress->progressOf($course),
|
||||
'sessions' => array_map(
|
||||
static fn (CourseSession $s): array => $s->toArray(),
|
||||
$sessions,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function requirePatient(User $user, string $uuid): PatientRecord
|
||||
{
|
||||
$patient = $this->patients->findOneBy(['uuid' => $uuid]);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($patient === null
|
||||
|| $patient->getEntityType() !== $entityType
|
||||
|| $patient->getEntityId() !== $entityId
|
||||
) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $patient;
|
||||
}
|
||||
|
||||
private function requireCourse(User $user, string $uuid): TreatmentCourse
|
||||
{
|
||||
$course = $this->courses->findByUuid($uuid);
|
||||
[$entityType, $entityId] = $this->branches->pair($user);
|
||||
|
||||
if ($course === null || !$this->ownership->belongsToPair($entityType, $entityId, $course)) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $course;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Repository\CourseProtocolRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* پروتکل دوره — «لیزر فولبادی: ۸ جلسه، ۲۱/۲۸/۴۵ روز».
|
||||
*
|
||||
* سه فاصله سه معنای متفاوت دارند: `min` زودترین زمانی که از نظر درمانی مجاز است،
|
||||
* `ideal` بهترین، و `max` جایی که دیرتر از آن اثر دوره افت میکند. برنامهریز به
|
||||
* **نزدیکترین به ایدهآل** میرسد، نه اولین وقت خالی.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CourseProtocolRepository::class)]
|
||||
#[ORM\Table(name: 'course_protocols')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_protocol_service', columns: ['service_item_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_protocols_tenant')]
|
||||
class CourseProtocol
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'min_days', type: 'smallint')]
|
||||
private int $minDays;
|
||||
|
||||
#[ORM\Column(name: 'ideal_days', type: 'smallint')]
|
||||
private int $idealDays;
|
||||
|
||||
#[ORM\Column(name: 'max_days', type: 'smallint')]
|
||||
private int $maxDays;
|
||||
|
||||
#[ORM\Column(name: 'prefer_same_resource', type: 'boolean', options: ['default' => true])]
|
||||
private bool $preferSameResource = true;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
/** @var Collection<int, CourseProtocolStep> */
|
||||
#[ORM\OneToMany(targetEntity: CourseProtocolStep::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['sessionNumber' => 'ASC'])]
|
||||
private Collection $steps;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(ServiceItem $serviceItem, int $sessionCount, int $minDays, int $idealDays, int $maxDays)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->serviceItem = $serviceItem;
|
||||
$this->steps = new ArrayCollection();
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
|
||||
$this->setShape($sessionCount, $minDays, $idealDays, $maxDays);
|
||||
|
||||
$section = $serviceItem->getSection();
|
||||
$this->assignTenantPair($section->getEntityType(), $section->getEntityId());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException وقتی فاصلهها ناسازگارند
|
||||
*/
|
||||
public function setShape(int $sessionCount, int $minDays, int $idealDays, int $maxDays): self
|
||||
{
|
||||
// دورهٔ یکجلسهای همان نوبت تکی است و به دوره نیازی ندارد.
|
||||
if ($sessionCount < 2) {
|
||||
throw new \InvalidArgumentException('A course needs at least two sessions.');
|
||||
}
|
||||
|
||||
if (!($minDays <= $idealDays && $idealDays <= $maxDays)) {
|
||||
throw new \InvalidArgumentException('Course spacing must satisfy min <= ideal <= max.');
|
||||
}
|
||||
|
||||
$this->sessionCount = $sessionCount;
|
||||
$this->minDays = $minDays;
|
||||
$this->idealDays = $idealDays;
|
||||
$this->maxDays = $maxDays;
|
||||
|
||||
return $this->touch();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
|
||||
public function getSessionCount(): int { return $this->sessionCount; }
|
||||
public function getMinDays(): int { return $this->minDays; }
|
||||
public function getIdealDays(): int { return $this->idealDays; }
|
||||
public function getMaxDays(): int { return $this->maxDays; }
|
||||
public function prefersSameResource(): bool { return $this->preferSameResource; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
|
||||
/** @return Collection<int, CourseProtocolStep> */
|
||||
public function getSteps(): Collection { return $this->steps; }
|
||||
|
||||
public function setPreferSameResource(bool $v): self { $this->preferSameResource = $v; return $this->touch(); }
|
||||
public function setActive(bool $v): self { $this->active = $v; return $this->touch(); }
|
||||
|
||||
public function addStep(CourseProtocolStep $step): self
|
||||
{
|
||||
if (!$this->steps->contains($step)) {
|
||||
$this->steps->add($step);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** پارامترهای جلسهٔ n — آرایهٔ خالی یعنی این جلسه پارامتری ندارد. */
|
||||
public function paramsFor(int $sessionNumber): array
|
||||
{
|
||||
foreach ($this->steps as $step) {
|
||||
if ($step->getSessionNumber() === $sessionNumber) {
|
||||
return $step->getParams();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function overrideDurationFor(int $sessionNumber): ?int
|
||||
{
|
||||
foreach ($this->steps as $step) {
|
||||
if ($step->getSessionNumber() === $sessionNumber) {
|
||||
return $step->getOverrideDurationMinutes();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function touch(): self
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'service_uuid' => $this->serviceItem->getUuid(),
|
||||
'service_name' => $this->serviceItem->getName(),
|
||||
'session_count' => $this->sessionCount,
|
||||
'min_days' => $this->minDays,
|
||||
'ideal_days' => $this->idealDays,
|
||||
'max_days' => $this->maxDays,
|
||||
'prefer_same_resource' => $this->preferSameResource,
|
||||
'active' => $this->active,
|
||||
'steps' => array_values(array_map(
|
||||
static fn (CourseProtocolStep $s): array => $s->toArray(),
|
||||
$this->steps->toArray(),
|
||||
)),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* پارامترهای یک جلسه از پروتکل — «جلسهٔ ۳: انرژی ۱۶».
|
||||
*
|
||||
* `params` عمداً آزاد است چون هر تخصص پارامتر خودش را دارد (انرژی، دوز، ضخامت)، ولی
|
||||
* فقط اسکالر و **هیچ منطقی به مقدارش وابسته نیست**: فقط کپی و نمایش میشود. لحظهای
|
||||
* که کدی روی `params['energy']` شرط بگذارد، این آزادی به بدهی تبدیل میشود.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'course_protocol_steps')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_step', columns: ['protocol_id', 'session_number'])]
|
||||
class CourseProtocolStep
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CourseProtocol::class, inversedBy: 'steps')]
|
||||
#[ORM\JoinColumn(name: 'protocol_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
#[ORM\Column(name: 'session_number', type: 'smallint')]
|
||||
private int $sessionNumber;
|
||||
|
||||
/** @var array<string, scalar> */
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $params = null;
|
||||
|
||||
#[ORM\Column(name: 'override_duration_minutes', type: 'smallint', nullable: true)]
|
||||
private ?int $overrideDurationMinutes = null;
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
public function __construct(CourseProtocol $protocol, int $sessionNumber, array $params = [], ?int $overrideDuration = null)
|
||||
{
|
||||
$this->protocol = $protocol;
|
||||
$this->sessionNumber = $sessionNumber;
|
||||
$this->params = self::scalarsOnly($params);
|
||||
$this->overrideDurationMinutes = $overrideDuration;
|
||||
|
||||
$protocol->addStep($this);
|
||||
}
|
||||
|
||||
/** تودرتویی پذیرفته نمیشود: پارامتری که ساختار دارد، منطق پنهان دارد. */
|
||||
private static function scalarsOnly(array $params): array
|
||||
{
|
||||
return array_filter($params, static fn (mixed $v): bool => is_scalar($v) || $v === null);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getProtocol(): CourseProtocol { return $this->protocol; }
|
||||
public function getSessionNumber(): int { return $this->sessionNumber; }
|
||||
public function getParams(): array { return $this->params ?? []; }
|
||||
public function getOverrideDurationMinutes(): ?int { return $this->overrideDurationMinutes; }
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'session_number' => $this->sessionNumber,
|
||||
'params' => (object) ($this->params ?? []),
|
||||
'override_duration_minutes' => $this->overrideDurationMinutes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* یک جلسه از دوره — برنامهریزیشده، رزروشده، انجامشده یا ردشده.
|
||||
*
|
||||
* `params` از پروتکل **کپی** میشود: بیمار جلسهٔ سوم را با انرژی ۱۶ انجام داده، و اگر
|
||||
* پروتکل فردا عوض شود، پروندهٔ او نباید بگوید ۱۸ بوده.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: CourseSessionRepository::class)]
|
||||
#[ORM\Table(name: 'course_sessions')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_course_session', columns: ['course_id', 'session_number'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_session_appointment', columns: ['appointment_id'])]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_sessions_tenant')]
|
||||
#[ORM\Index(columns: ['course_id', 'session_number'], name: 'idx_sessions_course')]
|
||||
class CourseSession
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_PLANNED = 'planned';
|
||||
public const STATUS_BOOKED = 'booked';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TreatmentCourse::class, inversedBy: 'sessions')]
|
||||
#[ORM\JoinColumn(name: 'course_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private TreatmentCourse $course;
|
||||
|
||||
#[ORM\Column(name: 'session_number', type: 'smallint')]
|
||||
private int $sessionNumber;
|
||||
|
||||
/** @var array<string, scalar>|null */
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $params = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Appointment::class)]
|
||||
#[ORM\JoinColumn(name: 'appointment_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Appointment $appointment = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 12, options: ['default' => self::STATUS_PLANNED])]
|
||||
private string $status = self::STATUS_PLANNED;
|
||||
|
||||
#[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)]
|
||||
private ?int $completedAt = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
public function __construct(TreatmentCourse $course, int $sessionNumber, array $params = [])
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->course = $course;
|
||||
$this->sessionNumber = $sessionNumber;
|
||||
$this->params = $params === [] ? null : $params;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
|
||||
$this->assignTenantPair($course->getEntityType(), $course->getEntityId());
|
||||
$course->addSession($this);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getCourse(): TreatmentCourse { return $this->course; }
|
||||
public function getSessionNumber(): int { return $this->sessionNumber; }
|
||||
public function getParams(): array { return $this->params ?? []; }
|
||||
public function getAppointment(): ?Appointment { return $this->appointment; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getCompletedAt(): ?int { return $this->completedAt; }
|
||||
|
||||
public function markBooked(Appointment $appointment): self
|
||||
{
|
||||
$this->appointment = $appointment;
|
||||
$this->status = self::STATUS_BOOKED;
|
||||
|
||||
return $this->touch();
|
||||
}
|
||||
|
||||
/** لغو نوبت جلسه را به `planned` برمیگرداند؛ بقیهٔ دوره دستنخورده میماند. */
|
||||
public function unbook(): self
|
||||
{
|
||||
$this->appointment = null;
|
||||
$this->status = self::STATUS_PLANNED;
|
||||
|
||||
return $this->touch();
|
||||
}
|
||||
|
||||
public function markCompleted(?int $at = null): self
|
||||
{
|
||||
$this->status = self::STATUS_COMPLETED;
|
||||
$this->completedAt = $at ?? time();
|
||||
|
||||
return $this->touch();
|
||||
}
|
||||
|
||||
public function markSkipped(): self
|
||||
{
|
||||
$this->status = self::STATUS_SKIPPED;
|
||||
|
||||
return $this->touch();
|
||||
}
|
||||
|
||||
private function touch(): self
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'session_number' => $this->sessionNumber,
|
||||
'params' => (object) ($this->params ?? []),
|
||||
'appointment_uuid' => $this->appointment?->getUuid(),
|
||||
'slot_start' => $this->appointment?->getSlotStart(),
|
||||
'status' => $this->status,
|
||||
'completed_at' => $this->completedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* دورهٔ درمان یک بیمار.
|
||||
*
|
||||
* چهار عدد پروتکل **کپی** میشوند نه ارجاع: تغییر پروتکل فردا نباید دورهٔ در جریان را
|
||||
* عوض کند — همان تصمیمی که در `appointment_segments` و `patient_packages` گرفته شد.
|
||||
*
|
||||
* یکتایی «یک دورهٔ فعال per (بیمار، سرویس)» با `activeCourseKey` گرفته میشود، همان
|
||||
* الگوی `Appointment::activeSlotKey`: MariaDB کلید یکتای جزئی ندارد، ولی کلیدی که در
|
||||
* حالتهای غیرفعال `null` میشود همان کار را میکند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: TreatmentCourseRepository::class)]
|
||||
#[ORM\Table(name: 'treatment_courses')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status', 'started_at'], name: 'idx_courses_tenant')]
|
||||
#[ORM\Index(columns: ['patient_record_id', 'status'], name: 'idx_courses_patient')]
|
||||
class TreatmentCourse
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_record_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private PatientRecord $patientRecord;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: CourseProtocol::class)]
|
||||
#[ORM\JoinColumn(name: 'protocol_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private CourseProtocol $protocol;
|
||||
|
||||
#[ORM\Column(name: 'session_count', type: 'smallint')]
|
||||
private int $sessionCount;
|
||||
|
||||
#[ORM\Column(name: 'min_days', type: 'smallint')]
|
||||
private int $minDays;
|
||||
|
||||
#[ORM\Column(name: 'ideal_days', type: 'smallint')]
|
||||
private int $idealDays;
|
||||
|
||||
#[ORM\Column(name: 'max_days', type: 'smallint')]
|
||||
private int $maxDays;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientPackage::class)]
|
||||
#[ORM\JoinColumn(name: 'patient_package_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?PatientPackage $patientPackage = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicResource::class)]
|
||||
#[ORM\JoinColumn(name: 'preferred_resource_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicResource $preferredResource = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 12, options: ['default' => self::STATUS_ACTIVE])]
|
||||
private string $status = self::STATUS_ACTIVE;
|
||||
|
||||
/** `null` وقتی دوره فعال نیست — همین باعث میشود کلید یکتا فقط فعالها را ببندد. */
|
||||
#[ORM\Column(name: 'active_course_key', type: 'string', length: 64, nullable: true, unique: true)]
|
||||
private ?string $activeCourseKey = null;
|
||||
|
||||
#[ORM\Column(name: 'abandon_reason', type: 'string', length: 255, nullable: true)]
|
||||
private ?string $abandonReason = null;
|
||||
|
||||
#[ORM\Column(name: 'started_at', type: 'integer')]
|
||||
private int $startedAt;
|
||||
|
||||
#[ORM\Column(name: 'completed_at', type: 'integer', nullable: true)]
|
||||
private ?int $completedAt = null;
|
||||
|
||||
/** @var Collection<int, CourseSession> */
|
||||
#[ORM\OneToMany(targetEntity: CourseSession::class, mappedBy: 'course', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['sessionNumber' => 'ASC'])]
|
||||
private Collection $sessions;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(PatientRecord $patientRecord, CourseProtocol $protocol)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->patientRecord = $patientRecord;
|
||||
$this->protocol = $protocol;
|
||||
$this->serviceItem = $protocol->getServiceItem();
|
||||
$this->sessionCount = $protocol->getSessionCount();
|
||||
$this->minDays = $protocol->getMinDays();
|
||||
$this->idealDays = $protocol->getIdealDays();
|
||||
$this->maxDays = $protocol->getMaxDays();
|
||||
$this->sessions = new ArrayCollection();
|
||||
$this->startedAt = time();
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
|
||||
$this->assignTenantPair($protocol->getEntityType(), $protocol->getEntityId());
|
||||
$this->refreshActiveKey();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getPatientRecord(): PatientRecord { return $this->patientRecord; }
|
||||
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
|
||||
public function getProtocol(): CourseProtocol { return $this->protocol; }
|
||||
public function getSessionCount(): int { return $this->sessionCount; }
|
||||
public function getMinDays(): int { return $this->minDays; }
|
||||
public function getIdealDays(): int { return $this->idealDays; }
|
||||
public function getMaxDays(): int { return $this->maxDays; }
|
||||
public function getPatientPackage(): ?PatientPackage { return $this->patientPackage; }
|
||||
public function getPreferredResource(): ?ClinicResource { return $this->preferredResource; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getAbandonReason(): ?string { return $this->abandonReason; }
|
||||
public function getStartedAt(): int { return $this->startedAt; }
|
||||
public function getCompletedAt(): ?int { return $this->completedAt; }
|
||||
public function isActive(): bool { return $this->status === self::STATUS_ACTIVE; }
|
||||
|
||||
/** @return Collection<int, CourseSession> */
|
||||
public function getSessions(): Collection { return $this->sessions; }
|
||||
|
||||
public function setPatientPackage(?PatientPackage $v): self { $this->patientPackage = $v; return $this->touch(); }
|
||||
public function setPreferredResource(?ClinicResource $v): self { $this->preferredResource = $v; return $this->touch(); }
|
||||
|
||||
public function addSession(CourseSession $session): self
|
||||
{
|
||||
if (!$this->sessions->contains($session)) {
|
||||
$this->sessions->add($session);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function abandon(string $reason): self
|
||||
{
|
||||
$this->status = self::STATUS_ABANDONED;
|
||||
$this->abandonReason = $reason;
|
||||
|
||||
return $this->refreshActiveKey()->touch();
|
||||
}
|
||||
|
||||
public function complete(?int $at = null): self
|
||||
{
|
||||
$this->status = self::STATUS_COMPLETED;
|
||||
$this->completedAt = $at ?? time();
|
||||
|
||||
return $this->refreshActiveKey()->touch();
|
||||
}
|
||||
|
||||
/** @return list<CourseSession> جلساتی که هنوز رزرو نشدهاند، به ترتیب شماره */
|
||||
public function plannedSessions(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->sessions->toArray(),
|
||||
static fn (CourseSession $s): bool => $s->getStatus() === CourseSession::STATUS_PLANNED,
|
||||
));
|
||||
}
|
||||
|
||||
/** آخرین جلسهٔ **انجامشده** — مبنای فاصلهٔ جلسهٔ بعدی. */
|
||||
public function lastCompletedAt(): ?int
|
||||
{
|
||||
$times = [];
|
||||
|
||||
foreach ($this->sessions as $session) {
|
||||
if ($session->getCompletedAt() !== null) {
|
||||
$times[] = $session->getCompletedAt();
|
||||
}
|
||||
}
|
||||
|
||||
return $times === [] ? null : max($times);
|
||||
}
|
||||
|
||||
public function completedCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->sessions->toArray(),
|
||||
static fn (CourseSession $s): bool => $s->getStatus() === CourseSession::STATUS_COMPLETED,
|
||||
));
|
||||
}
|
||||
|
||||
private function refreshActiveKey(): self
|
||||
{
|
||||
$this->activeCourseKey = $this->status === self::STATUS_ACTIVE
|
||||
? sprintf('%d:%d', $this->patientRecord->getId(), $this->serviceItem->getId())
|
||||
: null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function touch(): self
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'patient_uuid' => $this->patientRecord->getUuid(),
|
||||
'service_uuid' => $this->serviceItem->getUuid(),
|
||||
'service_name' => $this->serviceItem->getName(),
|
||||
'protocol_uuid' => $this->protocol->getUuid(),
|
||||
'session_count' => $this->sessionCount,
|
||||
'min_days' => $this->minDays,
|
||||
'ideal_days' => $this->idealDays,
|
||||
'max_days' => $this->maxDays,
|
||||
'patient_package_uuid' => $this->patientPackage?->getUuid(),
|
||||
'preferred_resource_uuid' => $this->preferredResource?->getUuid(),
|
||||
'status' => $this->status,
|
||||
'abandon_reason' => $this->abandonReason,
|
||||
'started_at' => $this->startedAt,
|
||||
'completed_at' => $this->completedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CourseProtocol> */
|
||||
class CourseProtocolRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CourseProtocol::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?CourseProtocol
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findForService(ServiceItem $service): ?CourseProtocol
|
||||
{
|
||||
return $this->findOneBy(['serviceItem' => $service]);
|
||||
}
|
||||
|
||||
/** @return CourseProtocol[] */
|
||||
public function findForPair(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->addSelect('s', 'i')
|
||||
->leftJoin('p.steps', 's')
|
||||
->leftJoin('p.serviceItem', 'i')
|
||||
->where('p.entityType = :type')
|
||||
->andWhere('p.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('p.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(CourseProtocol $protocol, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($protocol);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<CourseSession> */
|
||||
class CourseSessionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CourseSession::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?CourseSession
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findForAppointment(Appointment $appointment): ?CourseSession
|
||||
{
|
||||
return $this->findOneBy(['appointment' => $appointment]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/** @extends ServiceEntityRepository<TreatmentCourse> */
|
||||
class TreatmentCourseRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TreatmentCourse::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?TreatmentCourse
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findActiveFor(PatientRecord $patient, ServiceItem $service): ?TreatmentCourse
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'patientRecord' => $patient,
|
||||
'serviceItem' => $service,
|
||||
'status' => TreatmentCourse::STATUS_ACTIVE,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return TreatmentCourse[] */
|
||||
public function findForPatient(PatientRecord $patient): array
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->addSelect('s')
|
||||
->leftJoin('c.sessions', 's')
|
||||
->where('c.patientRecord = :patient')
|
||||
->setParameter('patient', $patient)
|
||||
->orderBy('c.startedAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(TreatmentCourse $course, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($course);
|
||||
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Availability\ValueObject\AvailableSlot;
|
||||
use App\Appointment\Booking\Service\BookingService;
|
||||
use App\Appointment\Booking\Service\HoldService;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رزرو یکجای جلسات باقیماندهٔ دوره.
|
||||
*
|
||||
* سه قاعده که ترتیبشان مهم است:
|
||||
*
|
||||
* ۱. **همه یا هیچ** — کل حلقه در یک تراکنش. رزرو نیمهکاره بدترین حالت است: بیمار فکر
|
||||
* میکند دورهاش رزرو شده و نصفش نیست.
|
||||
* ۲. **لنگر متحرک** — هر جلسه از جلسهٔ قبلی فاصله میگیرد، نه از شروع دوره.
|
||||
* ۳. **سقف افق جستجو** — جلساتی که بیرون بازهٔ مجاز میافتند `planned` میمانند و
|
||||
* پیام روشن برمیگردد؛ خطا نیستند.
|
||||
*/
|
||||
final class CourseBooker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseScheduler $scheduler,
|
||||
private readonly AppointmentPlanBuilder $planner,
|
||||
private readonly HoldService $holds,
|
||||
private readonly BookingService $booking,
|
||||
private readonly CourseSessionLinker $linker,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{booked: int, remaining: int, message: string|null}
|
||||
*/
|
||||
public function bookAll(TreatmentCourse $course, DoctorAddress $address, Doctor $doctor, User $operator, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
|
||||
return $this->em->wrapInTransaction(function () use ($course, $address, $doctor, $operator, $now): array {
|
||||
$planned = $course->plannedSessions();
|
||||
|
||||
usort($planned, static fn (CourseSession $a, CourseSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$anchor = $course->lastCompletedAt() ?? $now;
|
||||
$minDays = $this->scheduler->effectiveMinDays($course, $now);
|
||||
$horizon = $now + CourseScheduler::SEARCH_HORIZON_DAYS * 86400;
|
||||
|
||||
$booked = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($planned as $session) {
|
||||
$min = max($anchor + $minDays * 86400, $now);
|
||||
$ideal = $anchor + max($course->getIdealDays(), $minDays) * 86400;
|
||||
$max = $anchor + max($course->getMaxDays(), $minDays) * 86400;
|
||||
|
||||
if ($min > $horizon) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$slot = $this->scheduler->slotsFor($course, $address, $min, min($max, $horizon), $ideal, $now)[0] ?? null;
|
||||
|
||||
if ($slot === null) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('برای جلسهٔ %d هیچ وقت مناسبی در بازهٔ مجاز پیدا نشد', $session->getSessionNumber()),
|
||||
422,
|
||||
'session_number',
|
||||
);
|
||||
}
|
||||
|
||||
$this->bookOne($course, $session, $slot, $address, $doctor, $operator, $now);
|
||||
|
||||
$booked++;
|
||||
$anchor = $slot->start;
|
||||
}
|
||||
|
||||
return [
|
||||
'booked' => $booked,
|
||||
'remaining' => $skipped,
|
||||
'message' => $skipped === 0
|
||||
? null
|
||||
: sprintf(
|
||||
'%d جلسه بیرون از بازهٔ %d روزهٔ رزرو افتاد و برنامهریزیشده ماند؛ نزدیکتر که شدیم رزروشان کنید.',
|
||||
$skipped,
|
||||
CourseScheduler::SEARCH_HORIZON_DAYS,
|
||||
),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function bookOne(
|
||||
TreatmentCourse $course,
|
||||
CourseSession $session,
|
||||
AvailableSlot $slot,
|
||||
DoctorAddress $address,
|
||||
Doctor $doctor,
|
||||
User $operator,
|
||||
int $now,
|
||||
): void {
|
||||
$plan = $this->planner->build($course->getServiceItem(), [], $address);
|
||||
|
||||
$hold = $this->holds->hold(
|
||||
$operator,
|
||||
$plan,
|
||||
$this->assignmentOf($slot),
|
||||
$slot->start,
|
||||
$course->getEntityType(),
|
||||
$course->getEntityId(),
|
||||
$now,
|
||||
);
|
||||
|
||||
$appointment = new Appointment($doctor, $course->getPatientRecord()->getUser(), $slot->start, $slot->end);
|
||||
$appointment->assignTenantPair($course->getEntityType(), $course->getEntityId());
|
||||
$appointment->setServiceItem($course->getServiceItem());
|
||||
$appointment->setAddressId($address->getId());
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$this->booking->confirm($hold, $appointment, $now);
|
||||
$this->linker->link($session, $appointment);
|
||||
}
|
||||
|
||||
/** @return array<string, list<ClinicResource>> */
|
||||
private function assignmentOf(AvailableSlot $slot): array
|
||||
{
|
||||
return $slot->assignment->byRole;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
|
||||
/**
|
||||
* پیشرفت دوره — «جلسهٔ ۳ از ۸».
|
||||
*
|
||||
* شمارش از خودِ جلسات میآید نه از یک شمارنده؛ همان دلیل دفتر اعتبار تسک ۱۱: عددی که
|
||||
* جدا از داده نگه داشته شود، بالاخره با آن اختلاف پیدا میکند.
|
||||
*/
|
||||
final class CourseProgressCalculator
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function progressOf(TreatmentCourse $course): array
|
||||
{
|
||||
$byStatus = [
|
||||
CourseSession::STATUS_PLANNED => 0,
|
||||
CourseSession::STATUS_BOOKED => 0,
|
||||
CourseSession::STATUS_COMPLETED => 0,
|
||||
CourseSession::STATUS_SKIPPED => 0,
|
||||
];
|
||||
|
||||
$next = null;
|
||||
|
||||
foreach ($course->getSessions() as $session) {
|
||||
$byStatus[$session->getStatus()]++;
|
||||
|
||||
if ($session->getStatus() === CourseSession::STATUS_PLANNED
|
||||
&& ($next === null || $session->getSessionNumber() < $next->getSessionNumber())
|
||||
) {
|
||||
$next = $session;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'completed' => $byStatus[CourseSession::STATUS_COMPLETED],
|
||||
'booked' => $byStatus[CourseSession::STATUS_BOOKED],
|
||||
'planned' => $byStatus[CourseSession::STATUS_PLANNED],
|
||||
'skipped' => $byStatus[CourseSession::STATUS_SKIPPED],
|
||||
'total' => $course->getSessionCount(),
|
||||
'next_session_number' => $next?->getSessionNumber(),
|
||||
'next_params' => (object) ($next?->getParams() ?? []),
|
||||
'last_completed_at' => $course->lastCompletedAt(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Availability\Service\AvailabilityEngine;
|
||||
use App\Appointment\Availability\ValueObject\AvailableSlot;
|
||||
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Policy\Entity\Policy;
|
||||
use App\Policy\Service\PolicyResolver;
|
||||
use App\Policy\Service\PolicySchema;
|
||||
|
||||
/**
|
||||
* برنامهریزی جلسات دوره: پیشنهاد جلسهٔ بعدی و رزرو یکجا.
|
||||
*
|
||||
* ## لنگر متحرک
|
||||
*
|
||||
* فاصله همیشه از **جلسهٔ قبلی** حساب میشود، نه از شروع دوره. اگر جلسهٔ ۲ سه روز دیرتر
|
||||
* افتاد، جلسهٔ ۳ هم جابهجا میشود — وگرنه تأخیر یک جلسه، فاصلهٔ بقیه را خراب میکند.
|
||||
*
|
||||
* ## نزدیکترین به ایدهآل، نه اولین آزاد
|
||||
*
|
||||
* ۲۸ روز ایدهآل است؛ روز ۲۱ (حداقلِ مجاز) از نظر درمانی بدتر از روز ۲۷ است. پس بین
|
||||
* وقتهای موجود، آن که فاصلهاش تا ایدهآل کمتر است برنده میشود.
|
||||
*/
|
||||
final class CourseScheduler
|
||||
{
|
||||
/** سقف جستجوی تسک ۰۶ — جلسات بیرون این بازه `planned` میمانند. */
|
||||
public const SEARCH_HORIZON_DAYS = 90;
|
||||
|
||||
public function __construct(
|
||||
private readonly AppointmentPlanBuilder $planner,
|
||||
private readonly AvailabilityEngine $availability,
|
||||
private readonly PolicyResolver $policies,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* فاصلهٔ مؤثر: سختگیرانهترین بین پروتکل دوره و قانون `spacing` تسک ۰۹.
|
||||
*
|
||||
* قانون کلینیک نباید با پروتکل بجنگد؛ هر کدام سختگیرتر بود همان اجرا میشود.
|
||||
*/
|
||||
public function effectiveMinDays(TreatmentCourse $course, ?int $at = null): int
|
||||
{
|
||||
$service = $course->getServiceItem();
|
||||
|
||||
$outcome = $this->policies->resolve(
|
||||
Policy::CATEGORY_SPACING,
|
||||
$course->getEntityType(),
|
||||
$course->getEntityId(),
|
||||
[
|
||||
'service_uuid' => $service->getUuid(),
|
||||
'catalog_category' => $service->getCatalogCategory()?->getUuid(),
|
||||
],
|
||||
null,
|
||||
$service,
|
||||
$at,
|
||||
);
|
||||
|
||||
return max($course->getMinDays(), (int) $outcome->effect(PolicySchema::EFFECT_MIN_DAYS_BETWEEN, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* پیشنهاد برای جلسهٔ بعدی: بازهٔ مجاز، تاریخ ایدهآل، چند وقت نزدیک به آن، و
|
||||
* هشدار عبور از حداکثر فاصله.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function suggestNext(TreatmentCourse $course, DoctorAddress $address, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
$session = $this->nextPlanned($course);
|
||||
|
||||
if ($session === null) {
|
||||
return ['session_number' => null, 'suggested_slots' => [], 'warning' => 'همهٔ جلسات این دوره برنامهریزی شدهاند'];
|
||||
}
|
||||
|
||||
$anchor = $course->lastCompletedAt() ?? $course->getStartedAt();
|
||||
$minDays = $this->effectiveMinDays($course, $now);
|
||||
|
||||
$min = $anchor + $minDays * 86400;
|
||||
$ideal = $anchor + max($course->getIdealDays(), $minDays) * 86400;
|
||||
$max = $anchor + max($course->getMaxDays(), $minDays) * 86400;
|
||||
|
||||
// زمان گذشته پیشنهاد نمیشود؛ بیمارِ دیرکرده باید از همین حالا وقت بگیرد.
|
||||
$searchFrom = max($min, $now);
|
||||
$searchTo = max($max, $searchFrom + 86400);
|
||||
|
||||
$slots = $this->slotsFor($course, $address, $searchFrom, $searchTo, $ideal, $now);
|
||||
|
||||
return [
|
||||
'session_number' => $session->getSessionNumber(),
|
||||
'params' => (object) $session->getParams(),
|
||||
'ideal_at' => $ideal,
|
||||
'range' => ['min' => $min, 'max' => $max],
|
||||
'suggested_slots' => array_map(
|
||||
static fn (AvailableSlot $s): array => ['start' => $s->start, 'end' => $s->end],
|
||||
array_slice($slots, 0, 3),
|
||||
),
|
||||
// هشدار وقتی معنا دارد که واقعاً دیر شده باشد، نه وقتی هنوز فرصت هست.
|
||||
'warning' => $now > $max
|
||||
? sprintf('از حداکثر فاصلهٔ مجاز (%d روز) عبور شده است. برای ادامهٔ دوره با پزشک مشورت کنید.', $course->getMaxDays())
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* نزدیکترین وقت به ایدهآل، داخل بازهٔ مجاز.
|
||||
*
|
||||
* @return AvailableSlot[] مرتب بر اساس فاصله تا ایدهآل
|
||||
*/
|
||||
public function slotsFor(
|
||||
TreatmentCourse $course,
|
||||
DoctorAddress $address,
|
||||
int $from,
|
||||
int $to,
|
||||
int $ideal,
|
||||
?int $now = null,
|
||||
): array {
|
||||
$plan = $this->planner->build($course->getServiceItem(), [], $address);
|
||||
$slots = $this->availability->search($plan, $address, $from, $to, now: $now);
|
||||
|
||||
usort($slots, static fn (AvailableSlot $a, AvailableSlot $b): int
|
||||
=> abs($a->start - $ideal) <=> abs($b->start - $ideal));
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
public function nextPlanned(TreatmentCourse $course): ?CourseSession
|
||||
{
|
||||
$planned = $course->plannedSessions();
|
||||
|
||||
usort($planned, static fn (CourseSession $a, CourseSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
return $planned[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\CourseSessionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها جایی که پیوند «نوبت ↔ جلسهٔ دوره» نوشته میشود.
|
||||
*
|
||||
* پیوند دوطرفه است (`course_sessions.appointment_id` و `appointments.course_session_id`)
|
||||
* تا لیست نوبتهای پنل بدون JOIN بفهمد نوبت جزو دوره است و صفحهٔ دوره بدون JOIN نوبت را
|
||||
* پیدا کند. دو ستون یعنی دو فرصت برای واگرایی، پس **فقط این کلاس** مینویسدشان.
|
||||
*/
|
||||
final class CourseSessionLinker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CourseSessionRepository $sessions,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function link(CourseSession $session, Appointment $appointment): void
|
||||
{
|
||||
$session->markBooked($appointment);
|
||||
$appointment->setCourseSession($session);
|
||||
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* لغو نوبت: همان جلسه به `planned` برمیگردد و بقیهٔ دوره دستنخورده میماند.
|
||||
*
|
||||
* @return bool `false` یعنی این نوبت اصلاً جزو دورهای نبود
|
||||
*/
|
||||
public function unlink(Appointment $appointment): bool
|
||||
{
|
||||
$session = $this->sessions->findForAppointment($appointment);
|
||||
|
||||
if ($session === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session->unbook();
|
||||
$appointment->setCourseSession(null);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* جلسه انجام شد. دوره وقتی کامل میشود که **همهٔ** جلساتش تمام شده باشند —
|
||||
* نه وقتی آخرین جلسه رزرو شد.
|
||||
*/
|
||||
public function complete(Appointment $appointment, ?int $at = null): bool
|
||||
{
|
||||
$session = $this->sessions->findForAppointment($appointment);
|
||||
|
||||
if ($session === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$session->markCompleted($at);
|
||||
|
||||
$course = $session->getCourse();
|
||||
|
||||
if ($course->completedCount() >= $course->getSessionCount()) {
|
||||
$course->complete($at);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function courseOf(Appointment $appointment): ?TreatmentCourse
|
||||
{
|
||||
return $this->sessions->findForAppointment($appointment)?->getCourse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Course\Service;
|
||||
|
||||
use App\Course\Entity\CourseProtocol;
|
||||
use App\Course\Entity\CourseSession;
|
||||
use App\Course\Entity\TreatmentCourse;
|
||||
use App\Course\Repository\TreatmentCourseRepository;
|
||||
use App\Package\Entity\PatientPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* شروع دوره از روی پروتکل.
|
||||
*
|
||||
* همهٔ جلسات **همین لحظه** ساخته میشوند (با وضعیت `planned`) نه هنگام رزرو: بیمار باید
|
||||
* از روز اول ببیند «۸ جلسه» یعنی چه، و پارامتر هر جلسه هم همان لحظه از پروتکل کپی
|
||||
* میشود تا تغییر بعدی پروتکل پروندهٔ او را عوض نکند.
|
||||
*/
|
||||
final class CourseStarter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentCourseRepository $courses,
|
||||
) {}
|
||||
|
||||
public function start(
|
||||
PatientRecord $patient,
|
||||
CourseProtocol $protocol,
|
||||
?PatientPackage $package = null,
|
||||
): TreatmentCourse {
|
||||
if (!$protocol->isActive()) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این پروتکل غیرفعال است', 422, 'protocol_uuid');
|
||||
}
|
||||
|
||||
if ($patient->getEntityType() !== $protocol->getEntityType()
|
||||
|| $patient->getEntityId() !== $protocol->getEntityId()
|
||||
) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
|
||||
}
|
||||
|
||||
$existing = $this->courses->findActiveFor($patient, $protocol->getServiceItem());
|
||||
|
||||
if ($existing !== null) {
|
||||
// پیام شامل شناسهٔ دورهٔ موجود است تا اپراتور بتواند مستقیم برود سراغش،
|
||||
// نه اینکه دنبالش بگردد.
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
sprintf('این بیمار یک دورهٔ فعال برای همین خدمت دارد (%s)', $existing->getUuid()),
|
||||
422,
|
||||
'course_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
$course = new TreatmentCourse($patient, $protocol);
|
||||
|
||||
if ($package !== null) {
|
||||
$this->assertPackageCovers($package, $protocol);
|
||||
$course->setPatientPackage($package);
|
||||
}
|
||||
|
||||
for ($number = 1; $number <= $protocol->getSessionCount(); $number++) {
|
||||
new CourseSession($course, $number, $protocol->paramsFor($number));
|
||||
}
|
||||
|
||||
$this->courses->save($course);
|
||||
|
||||
return $course;
|
||||
}
|
||||
|
||||
/** پکیجی که این خدمت را پوشش نمیدهد، به این دوره وصل نمیشود. */
|
||||
private function assertPackageCovers(PatientPackage $package, CourseProtocol $protocol): void
|
||||
{
|
||||
$serviceId = (int) $protocol->getServiceItem()->getId();
|
||||
|
||||
if (!in_array($serviceId, $package->getPackage()->serviceIds(), true)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_VALIDATION_001,
|
||||
'پکیج انتخابشده این خدمت را پوشش نمیدهد',
|
||||
422,
|
||||
'patient_package_uuid',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,9 @@ final class GlobalTables
|
||||
// سرویسهای یک پکیج جزئی از تعریف همان پکیجاند، نه دادهٔ مستقل.
|
||||
\App\Package\Entity\PackageService::class => \App\Package\Entity\Package::class,
|
||||
|
||||
// پارامترهای هر جلسه جزئی از تعریف همان پروتکلاند.
|
||||
\App\Course\Entity\CourseProtocolStep::class => \App\Course\Entity\CourseProtocol::class,
|
||||
|
||||
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
|
||||
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user