feat(appointment): book with patient data, TTL lock, atomic conflict
book() now accepts for_self plus patient_* fields: for_self fills the patient from the paying user's profile, otherwise patient_name/mobile are required (422 if missing) and the rest are stored. Every booking starts pending with a 15-minute expires_at. Persisting goes through bookAtomically (re-check inside a transaction) so two concurrent requests for the same slot can't both win — the loser gets 409. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Appointment\Controller;
|
|||||||
|
|
||||||
use App\Appointment\Entity\Appointment;
|
use App\Appointment\Entity\Appointment;
|
||||||
use App\Appointment\Repository\AppointmentRepository;
|
use App\Appointment\Repository\AppointmentRepository;
|
||||||
|
use App\Appointment\Repository\SlotTakenException;
|
||||||
use App\Appointment\Service\SlotCalculatorService;
|
use App\Appointment\Service\SlotCalculatorService;
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Doctor\Repository\DoctorRepository;
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
@@ -184,14 +185,34 @@ class AppointmentController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
|
$forSelf = (bool) ($data['for_self'] ?? true);
|
||||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||||
if (isset($data['note'])) $appointment->setNote($data['note']);
|
if (isset($data['note'])) $appointment->setNote($data['note']);
|
||||||
|
|
||||||
$this->appointmentRepo->save($appointment);
|
if ($forSelf) {
|
||||||
|
$appointment->setPatientName($user->getRealName());
|
||||||
|
$appointment->setPatientMobile($user->getMobileNumber());
|
||||||
|
} else {
|
||||||
|
$patientName = trim($data['patient_name'] ?? '');
|
||||||
|
$patientMobile = trim($data['patient_mobile'] ?? '');
|
||||||
|
if ($patientName === '' || $patientMobile === '') {
|
||||||
|
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام و شماره موبایل بیمار الزامی است', 422);
|
||||||
|
}
|
||||||
|
$appointment->setPatientName($patientName);
|
||||||
|
$appointment->setPatientMobile($patientMobile);
|
||||||
|
$appointment->setPatientNationalCode($data['patient_national_code'] ?? null);
|
||||||
|
$appointment->setPatientGender($data['patient_gender'] ?? null);
|
||||||
|
$appointment->setPatientReason($data['patient_reason'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->appointmentRepo->bookAtomically($appointment);
|
||||||
|
} catch (SlotTakenException) {
|
||||||
|
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(['data' => $appointment->toArray()], 201);
|
return $this->success(['data' => $appointment->toArray()], 201);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ class AppointmentRepository extends ServiceEntityRepository
|
|||||||
return $this->findOneBy(['uuid' => $uuid]);
|
return $this->findOneBy(['uuid' => $uuid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a booking atomically: re-check the slot inside a transaction so
|
||||||
|
* two concurrent requests for the same slot cannot both succeed.
|
||||||
|
*
|
||||||
|
* @throws SlotTakenException if the slot is taken when the transaction commits
|
||||||
|
*/
|
||||||
|
public function bookAtomically(Appointment $appointment): void
|
||||||
|
{
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$em->wrapInTransaction(function () use ($em, $appointment): void {
|
||||||
|
if ($this->isSlotTaken($appointment->getDoctor(), $appointment->getSlotStart(), $appointment->getSlotEnd())) {
|
||||||
|
throw new SlotTakenException();
|
||||||
|
}
|
||||||
|
$em->persist($appointment);
|
||||||
|
$em->flush();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if a slot is already taken (confirmed or pending) */
|
/** Check if a slot is already taken (confirmed or pending) */
|
||||||
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\Repository;
|
||||||
|
|
||||||
|
class SlotTakenException extends \RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user