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>
This commit is contained in:
hamed
2026-07-31 09:28:55 +03:30
co-authored by Claude Opus 5
parent 5d93208383
commit 4395eea56e
18 changed files with 1848 additions and 77 deletions
@@ -33,7 +33,18 @@ class ResourceOccupancy
/** رزرو موقت تا پایان مهلت — تسک ۰۷ آن را مصرف می‌کند. */
public const STATUS_HOLD = 'hold';
public const STATUSES = [self::STATUS_BOOKED, self::STATUS_HOLD];
/**
* لغو یا منقضی — ردیف **حذف فیزیکی نمی‌شود**.
*
* تاریخچهٔ اینکه چه منبعی کِی گرفته شده بود، ورودی گزارش بهره‌وری است و حذفش یعنی
* پاک کردن همان چیزی که قرار است اندازه بگیریم.
*/
public const STATUS_RELEASED = 'released';
public const STATUSES = [self::STATUS_BOOKED, self::STATUS_HOLD, self::STATUS_RELEASED];
/** وضعیت‌هایی که واقعاً منبع را می‌گیرند. */
public const BLOCKING_STATUSES = [self::STATUS_BOOKED, self::STATUS_HOLD];
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -63,6 +74,10 @@ class ResourceOccupancy
#[ORM\Column(name: 'segment_name', type: 'string', length: 150, nullable: true)]
private ?string $segmentName = null;
/** رزرو موقتی که این اشغال از آن آمده؛ بعد از ثبت نهایی هم نگه داشته می‌شود. */
#[ORM\Column(name: 'hold_id', type: 'integer', nullable: true)]
private ?int $holdId = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -95,7 +110,13 @@ class ResourceOccupancy
public function getStatus(): string { return $this->status; }
public function getSegmentName(): ?string { return $this->segmentName; }
public function getHoldId(): ?int { return $this->holdId; }
public function setAppointmentId(?int $v): self { $this->appointmentId = $v; return $this; }
public function setHoldId(?int $v): self { $this->holdId = $v; return $this; }
public function markBooked(): self { $this->status = self::STATUS_BOOKED; return $this; }
public function markReleased(): self { $this->status = self::STATUS_RELEASED; return $this; }
public function setSegmentName(?string $v): self { $this->segmentName = $v; return $this; }
public function toArray(): array
@@ -34,6 +34,10 @@ class ResourceOccupancyRepository extends ServiceEntityRepository
->where('o.resource IN (:ids)')
->andWhere('o.startsAt < :to')
->andWhere('o.endsAt > :from')
// ردیف آزادشده تاریخچه است، نه اشغال؛ اگر شمرده شود، زمانِ لغوشده هرگز
// دوباره پیشنهاد نمی‌شود.
->andWhere('o.status IN (:blocking)')
->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
->setParameter('ids', $resourceIds)
->setParameter('from', $from)
->setParameter('to', $to)
@@ -0,0 +1,316 @@
<?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;
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Appointment\Booking\Entity;
use App\Appointment\Booking\Repository\AppointmentHoldRepository;
use App\Auth\Entity\User;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* رزرو موقت — مرحلهٔ میانیِ «جستجو → رزرو موقت → ثبت نهایی» (بند ۱۱ مستند).
*
* خودش نوبت نیست: هنوز بیمار قطعی نشده و پرداختی انجام نشده. ولی ردیف‌های
* `resource_occupancy` با وضعیت `hold` از همین لحظه ساخته می‌شوند تا همان زمان به
* کسِ دیگری پیشنهاد نشود.
*
* `payload` شکلِ برنامه و تخصیص منابع را نگه می‌دارد تا `confirm` مجبور نباشد دوباره
* جستجو کند — و مهم‌تر، نتیجه‌اش با چیزی که کاربر دیده فرق نکند.
*/
#[ORM\Entity(repositoryClass: AppointmentHoldRepository::class)]
#[ORM\Table(name: 'appointment_holds')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_hold_tenant')]
#[ORM\Index(columns: ['expires_at'], name: 'idx_hold_expiry')]
class AppointmentHold
{
use TenantOwnedTrait;
/** همان مهلتی که پرداخت نوبت دارد — دو عدد متفاوت یعنی دو حقیقت متفاوت. */
public const TTL_SECONDS = 900;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private User $user;
#[ORM\Column(name: 'starts_at', type: 'integer')]
private int $startsAt;
#[ORM\Column(name: 'ends_at', type: 'integer')]
private int $endsAt;
#[ORM\Column(name: 'expires_at', type: 'integer')]
private int $expiresAt;
#[ORM\Column(type: 'json')]
private array $payload = [];
#[ORM\Column(name: 'confirmed_at', type: 'integer', nullable: true)]
private ?int $confirmedAt = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
/** @param array<string, mixed> $payload */
public function __construct(
User $user,
string $entityType,
int $entityId,
int $startsAt,
int $endsAt,
array $payload,
?int $now = null,
) {
$now = $now ?? time();
$this->uuid = Uuid::v4()->toRfc4122();
$this->user = $user;
$this->startsAt = $startsAt;
$this->endsAt = $endsAt;
$this->expiresAt = $now + self::TTL_SECONDS;
$this->payload = $payload;
$this->createdAt = $now;
$this->assignTenantPair($entityType, $entityId);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getUser(): User { return $this->user; }
public function getStartsAt(): int { return $this->startsAt; }
public function getEndsAt(): int { return $this->endsAt; }
public function getExpiresAt(): int { return $this->expiresAt; }
public function getPayload(): array { return $this->payload; }
public function getConfirmedAt(): ?int { return $this->confirmedAt; }
public function isExpired(?int $now = null): bool
{
return ($now ?? time()) >= $this->expiresAt;
}
public function isConfirmed(): bool
{
return $this->confirmedAt !== null;
}
public function markConfirmed(?int $now = null): self
{
$this->confirmedAt = $now ?? time();
return $this;
}
public function toArray(): array
{
return [
'hold_uuid' => $this->uuid,
'starts_at' => $this->startsAt,
'ends_at' => $this->endsAt,
'expires_at' => $this->expiresAt,
'confirmed' => $this->isConfirmed(),
'assignment' => $this->payload['assignment'] ?? [],
];
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Appointment\Booking\Entity;
use App\Appointment\Booking\Repository\AppointmentSegmentRepository;
use App\Appointment\Entity\Appointment;
use Doctrine\ORM\Mapping as ORM;
/**
* بخش ثبت‌شدهٔ یک نوبت — عکسِ لحظهٔ ثبت از برنامه‌ای که تسک ۰۵ ساخته بود.
*
* فرزند aggregate با ریشهٔ {@see Appointment}. عمداً کپی است نه ارجاع به
* `SegmentTemplate`: الگو فردا عوض می‌شود و نوبتِ دیروز باید همان چیزی بماند که
* بیمار رزرو کرده.
*/
#[ORM\Entity(repositoryClass: AppointmentSegmentRepository::class)]
#[ORM\Table(name: 'appointment_segments')]
#[ORM\Index(columns: ['appointment_id', 'sequence'], name: 'idx_appointment_segment_seq')]
class AppointmentSegment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(name: 'appointment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Appointment $appointment;
#[ORM\Column(type: 'smallint')]
private int $sequence;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'starts_at', type: 'integer')]
private int $startsAt;
#[ORM\Column(name: 'ends_at', type: 'integer')]
private int $endsAt;
#[ORM\Column(name: 'patient_present', type: 'boolean', options: ['default' => true])]
private bool $patientPresent = true;
public function __construct(
Appointment $appointment,
int $sequence,
string $name,
int $startsAt,
int $endsAt,
bool $patientPresent = true,
) {
if ($endsAt <= $startsAt) {
throw new \InvalidArgumentException('Segment end must be after its start.');
}
$this->appointment = $appointment;
$this->sequence = $sequence;
$this->name = $name;
$this->startsAt = $startsAt;
$this->endsAt = $endsAt;
$this->patientPresent = $patientPresent;
}
public function getId(): ?int { return $this->id; }
public function getAppointment(): Appointment { return $this->appointment; }
public function getSequence(): int { return $this->sequence; }
public function getName(): string { return $this->name; }
public function getStartsAt(): int { return $this->startsAt; }
public function getEndsAt(): int { return $this->endsAt; }
public function isPatientPresent(): bool { return $this->patientPresent; }
public function toArray(): array
{
return [
'sequence' => $this->sequence,
'name' => $this->name,
'starts_at' => $this->startsAt,
'ends_at' => $this->endsAt,
'duration_minutes' => intdiv($this->endsAt - $this->startsAt, 60),
'patient_present' => $this->patientPresent,
];
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Appointment\Booking\Entity;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Resource\Entity\ClinicResource;
use Doctrine\ORM\Mapping as ORM;
/**
* تضمین یکتاییِ اشغال — **در سطح دیتابیس، نه در کد**.
*
* قانون سوم جمع‌بندی مستند: «جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد».
* MariaDB قید `EXCLUDE` بازه‌ای ندارد، پس هر بازهٔ اشغال به «سطل»های ثابت پنج‌دقیقه‌ای
* شکسته می‌شود و کلید یکتای `(resource_id, bucket_at, seat)` تداخل را غیرممکن می‌کند.
*
* `seat` ظرفیت را بیان می‌کند: اتاق سه‌تخته سه صندلی دارد (۰،۱،۲) و چهارمین رزرو
* هم‌زمان جایی برای نشستن پیدا نمی‌کند. بدون `seat`، ظرفیت را باید کد می‌شمرد و
* دقیقاً همان‌جا مسابقه شکل می‌گرفت.
*/
#[ORM\Entity]
#[ORM\Table(name: 'resource_occupancy_buckets')]
#[ORM\UniqueConstraint(name: 'uniq_bucket_resource_seat', columns: ['resource_id', 'bucket_at', 'seat'])]
#[ORM\Index(columns: ['occupancy_id'], name: 'idx_bucket_occupancy')]
class OccupancyBucket
{
/** دانهٔ زمانی. پنج دقیقه: ریزتر یعنی ردیف بیشتر، درشت‌تر یعنی رزرو دقیق ناممکن. */
public const BUCKET_SECONDS = 300;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: ClinicResource::class)]
#[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ClinicResource $resource;
#[ORM\ManyToOne(targetEntity: ResourceOccupancy::class)]
#[ORM\JoinColumn(name: 'occupancy_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ResourceOccupancy $occupancy;
#[ORM\Column(name: 'bucket_at', type: 'integer')]
private int $bucketAt;
#[ORM\Column(type: 'smallint')]
private int $seat;
public function __construct(ResourceOccupancy $occupancy, int $bucketAt, int $seat)
{
$this->occupancy = $occupancy;
$this->resource = $occupancy->getResource();
$this->bucketAt = $bucketAt;
$this->seat = $seat;
}
public function getId(): ?int { return $this->id; }
public function getBucketAt(): int { return $this->bucketAt; }
public function getSeat(): int { return $this->seat; }
/**
* سطل‌هایی که یک بازه لمس می‌کند.
*
* بازه نیم‌باز است، پس نوبتی که دقیقاً سرِ ساعت تمام می‌شود سطل بعدی را نمی‌گیرد.
*
* @return list<int>
*/
public static function bucketsFor(int $start, int $end): array
{
$first = intdiv($start, self::BUCKET_SECONDS) * self::BUCKET_SECONDS;
$buckets = [];
for ($at = $first; $at < $end; $at += self::BUCKET_SECONDS) {
$buckets[] = $at;
}
return $buckets;
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Appointment\Booking\Repository;
use App\Appointment\Booking\Entity\AppointmentHold;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AppointmentHold>
*/
class AppointmentHoldRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AppointmentHold::class);
}
public function findByUuid(string $uuid): ?AppointmentHold
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* hold هایی که مهلتشان گذشته و هنوز تبدیل به نوبت نشده‌اند.
*
* @return AppointmentHold[]
*/
public function findExpired(int $now, int $limit = 200): array
{
return $this->createQueryBuilder('h')
->where('h.expiresAt <= :now')
->andWhere('h.confirmedAt IS NULL')
->setParameter('now', $now)
->setMaxResults($limit)
->getQuery()
->getResult();
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Appointment\Booking\Repository;
use App\Appointment\Booking\Entity\AppointmentSegment;
use App\Appointment\Entity\Appointment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AppointmentSegment>
*/
class AppointmentSegmentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AppointmentSegment::class);
}
/** @return AppointmentSegment[] */
public function findForAppointment(Appointment $appointment): array
{
return $this->createQueryBuilder('s')
->where('s.appointment = :appointment')
->setParameter('appointment', $appointment)
->orderBy('s.sequence', 'ASC')
->getQuery()
->getResult();
}
}
@@ -0,0 +1,110 @@
<?php
namespace App\Appointment\Booking\Service;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\AppointmentSegment;
use App\Appointment\Entity\Appointment;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
/**
* ثبت نهایی از یک رزرو موقت، و آزادسازی هنگام لغو.
*
* تبدیل `hold → booked` هیچ منبعی را دوباره نمی‌گیرد: صندلی‌ها از لحظهٔ رزرو موقت
* گرفته شده‌اند و اینجا فقط برچسبشان عوض می‌شود. اگر ثبت نهایی دوباره رزرو می‌کرد،
* همان پنجرهٔ مسابقه‌ای که hold حذفش کرده بود برمی‌گشت.
*/
final class BookingService
{
public function __construct(
private readonly HoldService $holds,
private readonly EntityManagerInterface $em,
) {}
/**
* @throws AppException ۴۰۹ روی رزروِ منقضی یا ثبت‌شده
*/
public function confirm(AppointmentHold $hold, Appointment $appointment, ?int $now = null): Appointment
{
$now = $now ?? time();
if ($hold->isConfirmed()) {
throw new AppException(ErrorCodes::ERR_SLOT_TAKEN, 'این رزرو قبلاً ثبت شده است', 409);
}
if ($hold->isExpired($now)) {
throw new AppException(ErrorCodes::ERR_HOLD_EXPIRED, 'مهلت رزرو موقت تمام شده است', 409);
}
$occupancies = $this->holds->occupanciesOfHold($hold);
if ($occupancies === []) {
throw new AppException(ErrorCodes::ERR_HOLD_EXPIRED, 'رزرو موقت دیگر معتبر نیست', 409);
}
foreach ($occupancies as $occupancy) {
$occupancy->markBooked()->setAppointmentId($appointment->getId());
}
$this->writeSegments($hold, $appointment);
$hold->markConfirmed($now);
$this->em->flush();
return $appointment;
}
/**
* بخش‌های نوبت از همان `payload` رزرو ساخته می‌شوند، نه از الگوی امروزِ سرویس:
* الگو ممکن است بین رزرو و ثبت عوض شده باشد و نوبت باید همان چیزی بماند که کاربر
* دیده و پذیرفته.
*/
private function writeSegments(AppointmentHold $hold, Appointment $appointment): void
{
$segments = $hold->getPayload()['plan']['segments'] ?? [];
foreach ($segments as $segment) {
$start = $hold->getStartsAt() + (int) ($segment['offset_minutes'] ?? 0) * 60;
$end = $start + (int) ($segment['duration_minutes'] ?? 0) * 60;
if ($end <= $start) {
continue;
}
$this->em->persist(new AppointmentSegment(
$appointment,
(int) ($segment['sequence'] ?? 1),
(string) ($segment['name'] ?? '—'),
$start,
$end,
(bool) ($segment['patient_present'] ?? true),
));
}
}
/**
* لغو: ردیف‌های اشغال `released` می‌شوند، **حذف فیزیکی نمی‌شوند**.
* تاریخچه ورودی گزارش بهره‌وری است.
*/
public function cancel(Appointment $appointment): int
{
$occupancies = $this->em->getRepository(ResourceOccupancy::class)
->findBy(['appointmentId' => $appointment->getId()]);
$this->holds->release($occupancies);
return count($occupancies);
}
/** رزروِ منقضی: همان آزادسازی، ولی از سمت رزرو موقت. */
public function releaseHold(AppointmentHold $hold): int
{
$occupancies = $this->holds->occupanciesOfHold($hold);
$this->holds->release($occupancies);
return count($occupancies);
}
}
@@ -0,0 +1,234 @@
<?php
namespace App\Appointment\Booking\Service;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\OccupancyBucket;
use App\Appointment\Plan\ValueObject\AppointmentPlan;
use App\Auth\Entity\User;
use App\Resource\Entity\ClinicResource;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
/**
* رزرو موقت چندمنبعی.
*
* ## چرا دیتابیس، نه کد
*
* قانون سوم جمع‌بندی مستند: «جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد».
* هر بررسیِ «آیا آزاد است؟» در PHP، بین خواندن و نوشتن یک پنجرهٔ مسابقه دارد؛ دو
* درخواست هم‌زمان هر دو «آزاد» می‌بینند و هر دو می‌نویسند.
*
* پس تضمین روی کلید یکتای `(resource_id, bucket_at, seat)` است. کد فقط `INSERT`
* می‌زند و اگر دیتابیس ردش کرد، همان یعنی «گرفته شده».
*
* ## `seat` و ظرفیت
*
* منبع با ظرفیت ۳ سه صندلی دارد. تلاش از صندلی ۰ شروع می‌شود و با هر برخورد یک شماره
* جلو می‌رود؛ وقتی همهٔ صندلی‌ها پر شد، `409` برمی‌گردد. شمردنِ ظرفیت در PHP همان
* مسابقه‌ای را می‌ساخت که این طراحی حذفش می‌کند.
*/
final class HoldService
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
/**
* @param array<string, list<ClinicResource>> $assignment نقش => منابع انتخابی
* @throws AppException ۴۰۹ وقتی حتی یک منبع در حتی یک سطل جا ندارد
*/
public function hold(
User $user,
AppointmentPlan $plan,
array $assignment,
int $startsAt,
string $entityType,
int $entityId,
?int $now = null,
): AppointmentHold {
$now = $now ?? time();
$endsAt = $startsAt + $plan->totalMinutes * 60;
$reserved = $this->intervalsFor($plan, $assignment, $startsAt);
if ($reserved === []) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'برای این زمان هیچ منبعی مشخص نشده است',
422,
'assignment',
);
}
$hold = new AppointmentHold(
$user,
$entityType,
$entityId,
$startsAt,
$endsAt,
[
'assignment' => $this->describeAssignment($assignment),
'plan' => $plan->toArray(),
],
$now,
);
$this->em->persist($hold);
$this->em->flush();
// اگر منبع دوم جا نداشت، اولی هم باید آزاد شود: رزرو نیمه‌کاره یعنی منبعی
// قفل بماند که هرگز نوبتی رویش ثبت نمی‌شود.
$taken = [];
try {
foreach ($reserved as $row) {
$taken[] = $this->reserve($row['resource'], $row['start'], $row['end'], $hold, $row['segment']);
}
} catch (AppException $e) {
$this->release($taken);
$this->em->remove($hold);
$this->em->flush();
throw $e;
}
return $hold;
}
/**
* یک بازه را برای یک منبع می‌گیرد، با اولین صندلی آزاد.
*
* سطل‌ها با DBAL خام نوشته می‌شوند نه با ORM: برخورد کلید یکتا در `flush()`
* خودِ EntityManager را می‌بندد و تلاش صندلی بعدی هم با «EntityManager is closed»
* می‌شکست. با DBAL، استثنا فقط یک استثناست و حلقه ادامه می‌یابد.
*
* @throws AppException ۴۰۹ وقتی همهٔ صندلی‌ها گرفته‌اند
*/
private function reserve(
ClinicResource $resource,
int $start,
int $end,
AppointmentHold $hold,
?string $segmentName,
): ResourceOccupancy {
$buckets = OccupancyBucket::bucketsFor($start, $end);
$connection = $this->em->getConnection();
for ($seat = 0; $seat < $resource->getCapacity(); $seat++) {
$occupancy = new ResourceOccupancy($resource, $start, $end, ResourceOccupancy::STATUS_HOLD);
$occupancy->setSegmentName($segmentName);
$occupancy->setHoldId($hold->getId());
$this->em->persist($occupancy);
$this->em->flush();
try {
foreach ($buckets as $bucketAt) {
$connection->insert('resource_occupancy_buckets', [
'resource_id' => $resource->getId(),
'occupancy_id' => $occupancy->getId(),
'bucket_at' => $bucketAt,
'seat' => $seat,
]);
}
return $occupancy;
} catch (UniqueConstraintViolationException) {
// این صندلی همین حالا گرفته شد. سطل‌های نیمه‌نوشته و خودِ ردیف اشغال
// پاک می‌شوند تا صندلی بعدی از صفر شروع کند.
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
$this->em->remove($occupancy);
$this->em->flush();
}
}
throw new AppException(
ErrorCodes::ERR_SLOT_TAKEN,
sprintf('«%s» در این زمان ظرفیت خالی ندارد', $resource->getName()),
409,
'assignment',
);
}
/**
* آزادسازی: وضعیت `released` و پاک کردن سطل‌ها.
*
* خودِ ردیف اشغال می‌ماند چون تاریخچهٔ بهره‌وری است؛ ولی سطل‌ها باید بروند وگرنه
* کلید یکتا آن زمان را برای همیشه قفل نگه می‌دارد.
*
* @param list<ResourceOccupancy> $occupancies
*/
public function release(array $occupancies): void
{
if ($occupancies === []) {
return;
}
$connection = $this->em->getConnection();
foreach ($occupancies as $occupancy) {
$connection->delete('resource_occupancy_buckets', ['occupancy_id' => $occupancy->getId()]);
$occupancy->markReleased();
}
$this->em->flush();
}
/** @return list<ResourceOccupancy> */
public function occupanciesOfHold(AppointmentHold $hold): array
{
return $this->em->getRepository(ResourceOccupancy::class)->findBy(['holdId' => $hold->getId()]);
}
/**
* بازه‌های اشغال: **per نقش**، نه per بخش.
*
* منبعی که در یک بخش نیازمندی ندارد، برای آن دقایق ردیف اشغال هم ندارد — همان
* چیزی که ظرفیت را آزاد می‌کند (بند ۷ مستند).
*
* @param array<string, list<ClinicResource>> $assignment
* @return list<array{resource: ClinicResource, start: int, end: int, segment: ?string}>
*/
private function intervalsFor(AppointmentPlan $plan, array $assignment, int $startsAt): array
{
$rows = [];
foreach ($plan->segments as $segment) {
foreach ($segment->requirements as $requirement) {
foreach ($assignment[$requirement->role] ?? [] as $resource) {
$segmentStart = $startsAt + $segment->offsetMinutes * 60;
$segmentEnd = $segmentStart + $segment->durationMinutes * 60;
$rows[] = [
'resource' => $resource,
// آماده‌سازی و تمیزکاری هم گرفته می‌شود: منبع واقعاً در آن
// دقایق در دسترس نیست.
'start' => $segmentStart - $requirement->setupMinutes * 60,
'end' => $segmentEnd + $requirement->cleanupMinutes * 60,
'segment' => $segment->name,
];
}
}
}
return $rows;
}
/** @param array<string, list<ClinicResource>> $assignment */
private function describeAssignment(array $assignment): array
{
$out = [];
foreach ($assignment as $role => $resources) {
$out[$role] = array_map(
static fn (ClinicResource $r): array => ['uuid' => $r->getUuid(), 'name' => $r->getName()],
$resources,
);
}
return $out;
}
}
@@ -3,6 +3,8 @@
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Booking\Repository\AppointmentHoldRepository;
use App\Appointment\Booking\Service\BookingService;
use App\Appointment\Repository\AppointmentRepository;
use App\Payment\Entity\Payment;
use App\Payment\Repository\PaymentRepository;
@@ -12,6 +14,8 @@ class AppointmentExpiryService
public function __construct(
private readonly AppointmentRepository $appointmentRepo,
private readonly PaymentRepository $paymentRepo,
private readonly AppointmentHoldRepository $holdRepo,
private readonly BookingService $booking,
) {}
/**
@@ -20,6 +24,25 @@ class AppointmentExpiryService
*
* @return int number of appointments expired
*/
/**
* رزروهای موقتی که مهلتشان گذشته و ثبت نهایی نشده‌اند.
*
* ردیف اشغال `released` می‌شود (تاریخچه می‌ماند) ولی سطل‌های یکتایی حذف می‌شوند،
* وگرنه کلید یکتا آن بازه را برای همیشه نگه می‌دارد.
*/
private function expireHolds(int $now): int
{
$holds = $this->holdRepo->findExpired($now);
$count = 0;
foreach ($holds as $hold) {
$this->booking->releaseHold($hold);
$count++;
}
return $count;
}
public function expireStale(): int
{
$now = time();
@@ -47,7 +70,14 @@ class AppointmentExpiryService
$count++;
}
if ($count > 0) {
// رزروهای موقتِ منقضی هم همین‌جا آزاد می‌شوند: بدونش، صندلیِ گرفته‌شده تا ابد
// قفل می‌ماند و آن زمان هرگز دوباره پیشنهاد نمی‌شود.
$count += $this->expireHolds($now);
// شرط روی `$expired` است نه `$count`: از وقتی رزروهای موقت هم شمرده می‌شوند،
// `$count` می‌تواند مثبت باشد در حالی که هیچ نوبتی منقضی نشده — و آن‌وقت
// `reset([])` مقدار `false` به save می‌داد. (آزادسازی رزروها خودش flush دارد.)
if ($expired !== []) {
$this->appointmentRepo->save(reset($expired)); // flush once
}