Merge branch 'feat/appointment-lock-patient'
# Conflicts: # src/Appointment/Controller/AppointmentController.php
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() ?? [];
|
||||
|
||||
Reference in New Issue
Block a user