> $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 $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 */ public function occupanciesOfHold(AppointmentHold $hold): array { return $this->em->getRepository(ResourceOccupancy::class)->findBy(['holdId' => $hold->getId()]); } /** * بازه‌های اشغال: **per نقش**، نه per بخش. * * منبعی که در یک بخش نیازمندی ندارد، برای آن دقایق ردیف اشغال هم ندارد — همان * چیزی که ظرفیت را آزاد می‌کند (بند ۷ مستند). * * @param array> $assignment * @return list */ 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> $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; } }