# معماری — تسک ۰۷ ## ساختار فایل ``` src/Appointment/Booking/ ├── Entity/ │ ├── ResourceOccupancy.php │ └── AppointmentSegment.php ├── Service/ │ ├── HoldService.php # رزرو موقت │ ├── BookingService.php # ثبت نهایی │ ├── RescheduleService.php │ └── OccupancyWriter.php # تنها نویسندهٔ resource_occupancy ├── Repository/ResourceOccupancyRepository.php ├── Controller/BookingController.php └── Exception/{SlotTakenException, HoldExpiredException}.php ``` ## تضمین یکتایی در MariaDB MariaDB نه `EXCLUDE USING gist` دارد نه `tsrange`. سه گزینه بررسی شد: | گزینه | مشکل | |---|---| | `SELECT … FOR UPDATE` سپس `INSERT` | درست است ولی قفل بازه‌ای نیست؛ با `gap lock` در InnoDB کار می‌کند ولی به سطح ایزولاسیون و ایندکس وابسته است و شکننده | | قفل توزیع‌شده (Redis / `GET_LOCK`) | تضمین را از دیتابیس به کد برمی‌گرداند — همان چیزی که مستند رد می‌کند | | **کلید یکتای سطل زمانی** ✅ | یکتایی واقعی در سطح schema، بدون قفل صریح | ### راه‌حل: سطل زمانی هر ردیف اشغال، به ازای هر «سطل» زمانی که اشغال می‌کند، یک ردیف در جدول کمکی می‌نویسد: ``` resource_occupancy ← بازهٔ واقعی [start_at, end_at) resource_occupancy_slot ← یک ردیف per (resource_id, bucket, unit_index) UNIQUE(resource_id, bucket, unit_index) ``` `bucket` = `floor(timestamp / BUCKET_SECONDS)`، با `BUCKET_SECONDS = 300` (۵ دقیقه). `unit_index` از ۰ تا `capacity-1` — ظرفیت هم‌زمان را بدون قفل مدل می‌کند. ```php // OccupancyWriter::write() — داخل یک تراکنش foreach ($this->buckets($start, $end) as $bucket) { for ($u = 0; $u < $unitsNeeded; $u++) { // اولین unit_index آزاد را با INSERT پیدا کن، نه با SELECT $inserted = false; for ($idx = 0; $idx < $capacity; $idx++) { try { $this->conn->insert('resource_occupancy_slot', [ 'resource_id' => $resourceId, 'bucket' => $bucket, 'unit_index' => $idx, 'occupancy_id' => $occupancyId, ]); $inserted = true; break; } catch (UniqueConstraintViolationException) { continue; } } if (!$inserted) throw new SlotTakenException(); } } ``` **چرا این درست است:** دو تراکنش هم‌زمان که هر دو `unit_index = 0` را می‌خواهند، یکی `UniqueConstraintViolationException` می‌گیرد. تضمین از دیتابیس می‌آید، نه از کد. هیچ پنجرهٔ زمانی بین بررسی و نوشتن وجود ندارد چون بررسی‌ای انجام نمی‌شود — فقط `INSERT`. **هزینه:** نوبت ۶۸ دقیقه‌ای با ۳ منبع ≈ ۳ منبع × ۱۴ سطل = ۴۲ ردیف. با ۱۰۰ نوبت در روز ۴٬۲۰۰ ردیف روزانه. جدول قابل پارتیشن‌بندی روی `bucket` و ردیف‌های گذشته آرشیو می‌شوند (دستور `app:occupancy:prune --older-than=90d`). **گرانولاریتی ۵ دقیقه:** یعنی نوبت‌ها به مضرب ۵ دقیقه گرد می‌شوند. با `slot_granularity` پیش‌فرض ۱۵ دقیقه (تسک ۰۶) هیچ محدودیت عملی نیست. اگر کلینیکی گام ۱ دقیقه بخواهد، این راه‌حل جواب نمی‌دهد و باید به `SELECT FOR UPDATE` رفت — در `docs/api/appointment-booking.md` صریح نوشته شود. ## `ResourceOccupancy` ```php class ResourceOccupancy { use TenantOwnedTrait; public const STATUS_HOLD = 'hold'; public const STATUS_BOOKED = 'booked'; public const STATUS_RELEASED = 'released'; private ClinicResource $resource; private ?Appointment $appointment = null; // null فقط برای مسدودسازی دستی private ?AppointmentSegment $segment = null; private int $startAt; // شامل setup منبع private int $endAt; // شامل cleanup منبع private int $units = 1; private string $occupancyKind; // exclusive | shared | passive private string $status; private ?int $expiresAt = null; // فقط برای hold } ``` **یک ردیف per (بخش × منبع)** — نه per نوبت. این همان چیزی است که آزادسازی ظرفیت را ممکن می‌کند: اپراتور در بخش انتظار هیچ ردیفی ندارد. ## `HoldService` ```php public function hold(HoldRequest $req): Hold { return $this->em->wrapInTransaction(function () use ($req) { // ۱. برنامه را دوباره بساز — به assignment کلاینت اعتماد نکن $plan = $this->planBuilder->build($req->toPlanRequest()); // ۲. assignment ارسالی را اعتبارسنجی کن: هر منبع واقعاً کاندید آن نیازمندی است؟ $assignment = $this->validateAssignment($plan, $req->assignment); // ۳. نوبت pending با expires_at $appointment = $this->createPendingAppointment($req, $plan); // ۴. بخش‌ها را ذخیره کن $segments = $this->persistSegments($appointment, $plan, $req->start); // ۵. اشغال‌ها — اینجا SlotTakenException ممکن است پرت شود $this->occupancyWriter->writeForHold($appointment, $segments, $assignment); return new Hold($appointment->getUuid(), $appointment->getExpiresAt()); }); } ``` مرحلهٔ ۱ و ۲ حیاتی‌اند: `assignment` از کلاینت می‌آید و اگر بی‌بررسی نوشته شود، کلاینت می‌تواند منبعِ محیط دیگر یا منبع نامناسب را به نوبت بچسباند — همان کلاس نشتی که در `POST /api/v1/my/appointment` پیدا شد (`docs/architecture/tenancy.md`). ## `BookingService` ```php public function confirm(string $holdUuid, User $user): Appointment { return $this->em->wrapInTransaction(function () use ($holdUuid, $user) { $appointment = $this->loadOwnHold($holdUuid, $user); // ۱ (404 اگر مال دیگری) $this->assertHoldAlive($appointment); // ۲ (409 اگر منقضی) $this->policies->assertEligibility($appointment); // ۳ قلاب تسک ۰۹ $this->transition($appointment, Appointment::STATUS_CONFIRMED);// ۴ $this->occupancyWriter->promoteToBooked($appointment); // ۵ $this->pricing->snapshot($appointment); // ۶ قلاب تسک ۰۸ $this->events->dispatch(new AppointmentBooked($appointment)); // ۷ تسک ۱۴ return $appointment; }); } ``` هفت مرحله، یک تراکنش. مستند بند ۱۱: «اگر یکی شکست بخورد، همه لغو می‌شوند». `dispatch` باید **بعد از** commit اجرا شود — با `messenger` و `DispatchAfterCurrentBusStamp` یا با یک `postFlush` صف کوچک. ## سازگاری با `active_slot_key` `active_slot_key` موجود **حذف نمی‌شود**. برای نوبت‌های حالت `slot`/`service` همان تضمین‌کننده می‌ماند. برای حالت `resource`: - `active_slot_key` همچنان پر می‌شود (پزشک یک منبع است و یکتایی‌اش مفید) - ردیف‌های `resource_occupancy` هم نوشته می‌شوند دو تور ایمنی موازی. هزینه‌اش ناچیز، سودش این است که مهاجرت هیچ لحظه‌ای بدون حفاظ نیست. ⚠️ یک استثنا: در حالت `resource`، ممکن است دو نوبت **مجاز** با همان `doctor + slot_start` وجود داشته باشد؟ نه — پزشک هم‌زمان دو بیمار ندارد و `capacity` منبعِ `type=doctor` طبق تسک ۰۲ اجباراً ۱ است. پس تضاد ندارند. ## انقضای hold `ExpireAppointmentsHandler` موجود توسعه می‌یابد: ```php // قبل: فقط status را expired می‌کرد // بعد: + آزادسازی ردیف‌های اشغال و حذف ردیف‌های سطل $this->occupancyWriter->releaseExpiredHolds($now); ``` `resource_occupancy_slot` ردیف‌های hold منقضی باید **حذف فیزیکی** شوند، وگرنه سطل اشغال می‌ماند. `resource_occupancy` خودش `status='released'` می‌گیرد و می‌ماند (برای آدیت). زمان‌بندی: `symfony/scheduler` موجود، هر دقیقه. علاوه بر آن، `hasRoom` تسک ۰۶ شرط `expires_at > now` را دارد پس hold منقضی حتی پیش از cron هم مانع نمی‌شود.