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