- 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.
236 lines
9.0 KiB
PHP
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;
|
|
}
|
|
}
|