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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user