Merge branch 'feat/appointment-lock-patient'

# Conflicts:
#	src/Appointment/Controller/AppointmentController.php
This commit is contained in:
hamed
2026-06-15 18:29:25 +03:30
10 changed files with 438 additions and 16 deletions
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:cancel-expired-appointments',
description: 'Marks pending appointments whose slot_start is in the past as expired',
description: 'Expires pending bookings whose 15-min payment window lapsed or whose slot time has passed',
)]
class CancelExpiredAppointmentsCommand extends Command
{
@@ -22,9 +22,14 @@ class CancelExpiredAppointmentsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): int
{
$expired = $this->appointmentRepo->findExpiredPending(time());
$count = 0;
$now = time();
$expired = [];
foreach ([...$this->appointmentRepo->findPaymentExpired($now), ...$this->appointmentRepo->findExpiredPending($now)] as $appointment) {
$expired[$appointment->getUuid()] = $appointment;
}
$count = 0;
foreach ($expired as $appointment) {
$appointment->transitionTo(Appointment::STATUS_EXPIRED);
$this->appointmentRepo->save($appointment, false);
@@ -32,7 +37,7 @@ class CancelExpiredAppointmentsCommand extends Command
}
if ($count > 0) {
$this->appointmentRepo->save($expired[0]); // flush once
$this->appointmentRepo->save(reset($expired)); // flush once
}
$output->writeln(sprintf('Expired %d appointments.', $count));
@@ -5,6 +5,7 @@ namespace App\Appointment\Controller;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User;
@@ -230,14 +231,34 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409);
}
$forSelf = (bool) ($data['for_self'] ?? true);
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
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);
}
+47
View File
@@ -25,6 +25,8 @@ class Appointment
public const STATUS_EXPIRED = 'expired';
public const STATUS_NO_SHOW = 'no_show';
public const PAYMENT_TTL = 900; // 15 minutes to pay before a pending booking expires
public const ALLOWED_TRANSITIONS = [
self::STATUS_PENDING => [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED],
self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW],
@@ -63,6 +65,24 @@ class Appointment
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $note = null;
#[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)]
private ?int $expiresAt = null;
#[ORM\Column(name: 'patient_name', type: 'string', length: 150, nullable: true)]
private ?string $patientName = null;
#[ORM\Column(name: 'patient_mobile', type: 'string', length: 20, nullable: true)]
private ?string $patientMobile = null;
#[ORM\Column(name: 'patient_national_code', type: 'string', length: 20, nullable: true)]
private ?string $patientNationalCode = null;
#[ORM\Column(name: 'patient_gender', type: 'string', length: 10, nullable: true)]
private ?string $patientGender = null;
#[ORM\Column(name: 'patient_reason', type: 'text', nullable: true)]
private ?string $patientReason = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -89,8 +109,26 @@ class Appointment
public function getStatus(): string { return $this->status; }
public function getNote(): ?string { return $this->note; }
public function getVersion(): int { return $this->version; }
public function getExpiresAt(): ?int { return $this->expiresAt; }
public function getPatientName(): ?string { return $this->patientName; }
public function getPatientMobile(): ?string { return $this->patientMobile; }
public function getPatientNationalCode(): ?string { return $this->patientNationalCode; }
public function getPatientGender(): ?string { return $this->patientGender; }
public function getPatientReason(): ?string { return $this->patientReason; }
public function setNote(?string $v): self { $this->note = $v; return $this; }
public function setPatientName(?string $v): self { $this->patientName = $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; }
public function setPatientReason(?string $v): self { $this->patientReason = $v; return $this; }
public function markPendingWithTtl(int $ttl): self
{
$this->expiresAt = time() + $ttl;
$this->updatedAt = time();
return $this;
}
public function canTransitionTo(string $newStatus): bool
{
@@ -107,6 +145,9 @@ class Appointment
}
$this->status = $newStatus;
$this->updatedAt = time();
if ($newStatus !== self::STATUS_PENDING) {
$this->expiresAt = null;
}
return $this;
}
@@ -126,6 +167,12 @@ class Appointment
'slot_end' => $this->slotEnd,
'status' => $this->status,
'note' => $this->note,
'expires_at' => $this->expiresAt,
'patient_name' => $this->patientName,
'patient_mobile' => $this->patientMobile,
'patient_national_code' => $this->patientNationalCode,
'patient_gender' => $this->patientGender,
'patient_reason' => $this->patientReason,
'version' => $this->version,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
@@ -21,17 +21,39 @@ class AppointmentRepository extends ServiceEntityRepository
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) */
public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool
{
$qb = $this->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.doctor = :doctor')
->andWhere('a.status IN (:activeStatuses)')
->andWhere('a.slotStart < :slotEnd')
->andWhere('a.slotEnd > :slotStart')
->andWhere(
'a.status = :confirmed OR (a.status = :pending AND (a.expiresAt IS NULL OR a.expiresAt > :now))'
)
->setParameter('doctor', $doctor)
->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED])
->setParameter('confirmed', Appointment::STATUS_CONFIRMED)
->setParameter('pending', Appointment::STATUS_PENDING)
->setParameter('now', time())
->setParameter('slotStart', $slotStart)
->setParameter('slotEnd', $slotEnd);
@@ -58,6 +80,19 @@ class AppointmentRepository extends ServiceEntityRepository
return $this->findBy($criteria, ['slotStart' => 'DESC']);
}
/** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */
public function findPaymentExpired(int $now): array
{
return $this->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.expiresAt IS NOT NULL')
->andWhere('a.expiresAt < :now')
->setParameter('status', Appointment::STATUS_PENDING)
->setParameter('now', $now)
->getQuery()
->getResult();
}
/** @return Appointment[] pending appointments older than given timestamp */
public function findExpiredPending(int $before): array
{
@@ -0,0 +1,7 @@
<?php
namespace App\Appointment\Repository;
class SlotTakenException extends \RuntimeException
{
}
@@ -16,6 +16,7 @@ use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\CircuitBreakerService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsService;
use App\Sms\Service\SmsWalletService;
use App\Subscription\Service\SubscriptionService;
use OpenApi\Attributes as OA;
@@ -44,6 +45,7 @@ class PaymentController extends BaseController
private readonly CircuitBreakerService $circuitBreaker,
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly SmsService $smsService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly SiteConfigRepository $configRepo,
@@ -273,6 +275,8 @@ class PaymentController extends BaseController
$this->handleSubscriptionActivation($payment);
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
$this->handleSmsWalletCharge($payment);
} elseif ($payment->getType() === Payment::TYPE_APPOINTMENT) {
$this->handleAppointmentConfirmation($payment);
}
return $this->redirectToFrontend($payment, true);
@@ -590,6 +594,26 @@ class PaymentController extends BaseController
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
}
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
return;
}
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->appointmentRepo->save($appointment);
$mobile = $appointment->getPatientMobile();
if ($mobile) {
$when = date('Y-m-d H:i', $appointment->getSlotStart());
$this->smsService->dispatchAsync(
$mobile,
sprintf('نوبت شما با %s در تاریخ %s ثبت و تأیید شد.', $appointment->getDoctor()->getName(), $when)
);
}
}
private function handleSubscriptionActivation(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];