- 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.
9.1 KiB
معماری — تسک ۰۷
ساختار فایل
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 هم مانع نمیشود.