Files
hamed 021d0eb6b2 feat: implement cancellation policy, no-show tracking, and waitlist management
- Add implementation notes for cancellation and waitlist features.
- Create task documentation outlining goals, current status, and acceptance criteria for cancellation policy and resource utilization reporting.
- Establish architecture for domain events and outbox pattern to ensure reliable event publishing.
- Define database schema for domain events and necessary queries for resource utilization and plan accuracy reports.
- Implement detailed implementation notes covering edge cases, testing strategies, and documentation requirements.
2026-07-30 11:43:58 +03:30

9.1 KiB
Raw Permalink Blame History

معماری — تسک ۰۷

ساختار فایل

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 — ظرفیت هم‌زمان را بدون قفل مدل می‌کند.

// 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

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

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

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 موجود توسعه می‌یابد:

// قبل: فقط status را expired می‌کرد
// بعد: + آزادسازی ردیف‌های اشغال و حذف ردیف‌های سطل
$this->occupancyWriter->releaseExpiredHolds($now);

resource_occupancy_slot ردیف‌های hold منقضی باید حذف فیزیکی شوند، وگرنه سطل اشغال می‌ماند. resource_occupancy خودش status='released' می‌گیرد و می‌ماند (برای آدیت).

زمان‌بندی: symfony/scheduler موجود، هر دقیقه. علاوه بر آن، hasRoom تسک ۰۶ شرط expires_at > now را دارد پس hold منقضی حتی پیش از cron هم مانع نمی‌شود.