Files
clinicpro/src/Course/Service/CourseBooker.php
T
hamedandClaude Opus 5 fc504f4415 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>
2026-07-31 11:33:07 +03:30

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