Files
clinicpro/src/Appointment/Booking/Service/HoldService.php
T
hamed c4f1f25c80 Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote.
- Eliminated unused domain event publishing for policies and waitlist in Schedule.
- Cleaned up BookingEngineSeeder by removing package and policy related logic.
- Updated SeedScenariosCommand to reflect removal of policies from output.
- Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration.
- Removed domain event assertions from tests related to resource blocking.
2026-08-01 20:50:47 +03:30

236 lines
9.0 KiB
PHP

<?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;
}
}