Files
clinicpro/src/Appointment/Booking/Controller/BookingController.php
T
hamedandClaude Opus 5 4395eea56e feat(booking): multi-resource holds and confirmation with a database-level guarantee
Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.

MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.

Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.

Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.

A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.

Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.

Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.

The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.

12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.

1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:28:55 +03:30

317 lines
13 KiB
PHP

<?php
namespace App\Appointment\Booking\Controller;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Repository\AppointmentHoldRepository;
use App\Appointment\Booking\Service\BookingService;
use App\Appointment\Booking\Service\HoldService;
use App\Appointment\Entity\Appointment;
use App\Auth\Repository\UserRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Resource\Entity\ClinicResource;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use 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: 'Appointment Booking')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class BookingController extends BaseController
{
public function __construct(
private readonly AppointmentPlanBuilder $planner,
private readonly HoldService $holds,
private readonly BookingService $booking,
private readonly AppointmentHoldRepository $holdRepo,
private readonly ServiceItemRepository $items,
private readonly ClinicResourceRepository $resources,
private readonly DoctorRepository $doctors,
private readonly UserRepository $users,
private readonly BranchResolver $branches,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/v1/appointment-hold', name: 'appointment_hold_create', methods: ['POST'])]
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
foreach (['service_uuid', 'branch_uuid'] as $field) {
if (!is_string($data[$field] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, sprintf('فیلد %s الزامی است', $field), 422, $field);
}
}
if (!is_numeric($data['start'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد start الزامی است', 422, 'start');
}
if (!is_array($data['assignment'] ?? null) || $data['assignment'] === []) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد assignment الزامی است', 422, 'assignment');
}
$address = $this->branches->resolve($user, $data['branch_uuid']);
$service = $this->requireItem($user, $data['service_uuid']);
$selected = [];
foreach (($data['item_uuids'] ?? []) as $itemUuid) {
if (is_string($itemUuid)) {
$selected[] = $this->requireItem($user, $itemUuid);
}
}
$plan = $this->planner->build(
$service,
$selected,
$address,
is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null,
);
$assignment = $this->resolveAssignment($user, $data['assignment']);
$this->assertAssignmentCoversPlan($plan, $assignment);
[$entityType, $entityId] = $this->branches->pair($user);
$hold = $this->holds->hold(
$user,
$plan,
$assignment,
(int) $data['start'],
$entityType,
$entityId,
);
return $this->success($hold->toArray(), 201);
}
#[Route('/api/v1/appointment-hold/{uuid}', name: 'appointment_hold_release', methods: ['DELETE'])]
public function release(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$hold = $this->requireHold($user, $uuid);
if ($hold->isConfirmed()) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این رزرو ثبت نهایی شده و آزاد نمی‌شود', 422);
}
$this->booking->releaseHold($hold);
$this->em->remove($hold);
$this->em->flush();
return $this->success(null);
}
/**
* ثبت نهایی. نوبت با همان قرارداد موجود ساخته می‌شود تا مسیرهای فعلی
* (`active_slot_key`، رویدادها، پرداخت) دست‌نخورده بمانند — تور ایمنی دوگانه.
*/
#[Route('/api/v1/appointment-confirm', name: 'appointment_confirm', methods: ['POST'])]
public function confirm(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['hold_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد hold_uuid الزامی است', 422, 'hold_uuid');
}
$hold = $this->requireHold($user, $data['hold_uuid']);
$appointment = $this->makeAppointment($user, $hold, $data);
$this->em->persist($appointment);
$this->em->flush();
$this->booking->confirm($hold, $appointment);
return $this->success([
'appointment_uuid' => $appointment->getUuid(),
'starts_at' => $hold->getStartsAt(),
'ends_at' => $hold->getEndsAt(),
'assignment' => $hold->getPayload()['assignment'] ?? [],
]);
}
/**
* جابه‌جایی: **اول** رزرو جدید، بعد آزادسازی قدیم.
*
* ترتیب عمدی است — اگر رزرو جدید شکست بخورد، نوبت قدیمی دست‌نخورده می‌ماند و
* بیمار بی‌نوبت نمی‌شود. ترتیب برعکس، در بدترین حالت هر دو را از دست می‌داد.
*/
#[Route('/api/v1/appointment/{uuid}/rebook', name: 'appointment_rebook', methods: ['POST'])]
public function rebook(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['hold_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد hold_uuid الزامی است', 422, 'hold_uuid');
}
$appointment = $this->em->getRepository(Appointment::class)
->findOneBy(['uuid' => $uuid]);
[$entityType, $entityId] = $this->branches->pair($user);
if ($appointment === null || !$this->ownership->belongsToPair($entityType, $entityId, $appointment)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404);
}
$hold = $this->requireHold($user, $data['hold_uuid']);
// رزرو جدید از قبل گرفته شده؛ اینجا فقط تأیید و سپس آزادسازی قدیم.
$this->booking->confirm($hold, $appointment);
$released = $this->booking->cancel($appointment);
return $this->success([
'appointment_uuid' => $appointment->getUuid(),
'released_intervals' => $released,
'starts_at' => $hold->getStartsAt(),
]);
}
/**
* هر نیازمندی باید در `assignment` منبع داشته باشد. بدون این، رزرو موقت
* می‌توانست نصفِ منابع لازم را بگیرد و بقیه هنگام حضور بیمار کم بیاید.
*
* @param array<string, list<ClinicResource>> $assignment
*/
private function assertAssignmentCoversPlan(\App\Appointment\Plan\ValueObject\AppointmentPlan $plan, array $assignment): void
{
foreach ($plan->segments as $segment) {
foreach ($segment->requirements as $requirement) {
$given = count($assignment[$requirement->role] ?? []);
if ($given < $requirement->count) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('برای نقش «%s» منبع کافی انتخاب نشده است', $requirement->roleName),
422,
'assignment',
);
}
}
}
}
/**
* @param array<array-key, mixed> $raw
* @return array<string, list<ClinicResource>>
*/
private function resolveAssignment(User $user, array $raw): array
{
[$entityType, $entityId] = $this->branches->pair($user);
$assignment = [];
foreach ($raw as $role => $uuids) {
// کلیدِ عددی در JSON یعنی آرایه فرستاده‌اند نه شیء؛ نقش باید نام داشته باشد.
if (!is_array($uuids) || !is_string($role) || $role === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ساختار assignment نامعتبر است', 422, 'assignment');
}
foreach ($uuids as $resourceUuid) {
if (!is_string($resourceUuid)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'uuid منبع نامعتبر است', 422, 'assignment');
}
$resource = $this->resources->findByUuid($resourceUuid);
if ($resource === null || !$this->ownership->belongsToPair($entityType, $entityId, $resource)) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404);
}
$assignment[$role][] = $resource;
}
}
return $assignment;
}
/**
* نوبت با همان سازندهٔ موجود ساخته می‌شود، پس `active_slot_key` و رویدادها و
* مسیر پرداخت دقیقاً مثل قبل کار می‌کنند. اشغال چندمنبعی **کنار** آن می‌نشیند،
* نه به‌جایش — تور ایمنی دوگانه‌ای که خودِ تسک خواسته است.
*
* @param array<string, mixed> $data
*/
private function makeAppointment(User $user, AppointmentHold $hold, array $data): Appointment
{
if (!is_string($data['doctor_uuid'] ?? null)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد doctor_uuid الزامی است', 422, 'doctor_uuid');
}
$doctor = $this->doctors->findOneBy(['uuid' => $data['doctor_uuid']]);
if ($doctor === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
}
// بیمار پیش‌فرض خودِ کاربر است؛ منشی می‌تواند برای شخص دیگری ثبت کند.
$patient = $user;
if (is_string($data['patient_uuid'] ?? null)) {
$found = $this->users->findOneBy(['uuid' => $data['patient_uuid']]);
if ($found === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'بیمار یافت نشد', 404);
}
$patient = $found;
}
$appointment = new Appointment($doctor, $patient, $hold->getStartsAt(), $hold->getEndsAt());
$appointment->assignTenantPair($hold->getEntityType(), $hold->getEntityId());
return $appointment;
}
private function requireHold(User $user, string $uuid): AppointmentHold
{
$hold = $this->holdRepo->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
// رزرو کاربر دیگر ۴۰۴ می‌گیرد، نه ۴۰۳: وجودش نباید لو برود.
if ($hold === null
|| $hold->getUser()->getId() !== $user->getId()
|| !$this->ownership->belongsToPair($entityType, $entityId, $hold)
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'رزرو موقت یافت نشد', 404);
}
return $hold;
}
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;
}
}