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>
142 lines
5.4 KiB
PHP
142 lines
5.4 KiB
PHP
<?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;
|
|
}
|
|
}
|