Files
clinicpro/docs/new_feture/taskes/task-07-hold-and-book/architecture.md
T
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

183 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# معماری — تسک ۰۷
## ساختار فایل
```
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 هم مانع نمی‌شود.