- 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.
184 lines
8.9 KiB
Markdown
184 lines
8.9 KiB
Markdown
# نکات پیادهسازی — تسک ۰۷
|
||
|
||
## ۱. تست همزمانی واقعی، نه mock
|
||
|
||
این تسک بدون یک تست همزمانی واقعی تمام نیست. mock کردن `UniqueConstraintViolationException`
|
||
هیچ چیزی را اثبات نمیکند — چیزی که باید ثابت شود این است که **دیتابیس** جلویش را میگیرد.
|
||
|
||
```php
|
||
// tests/Appointment/ConcurrentHoldTest.php
|
||
$conn1 = $this->newConnection(); // دو اتصال مجزا، نه دو EntityManager روی یک اتصال
|
||
$conn2 = $this->newConnection();
|
||
|
||
$conn1->beginTransaction();
|
||
$conn2->beginTransaction();
|
||
|
||
$r1 = $this->tryHold($conn1, $resourceId, $bucket, 0);
|
||
$r2 = $this->tryHold($conn2, $resourceId, $bucket, 0); // باید بلاک یا شکست بخورد
|
||
|
||
$conn1->commit();
|
||
// دقیقاً یکی موفق
|
||
self::assertSame(1, (int) $r1['ok'] + (int) $r2['ok']);
|
||
```
|
||
|
||
اگر اجرای موازی واقعی در محیط CI سخت است، حداقل دو اتصال DBAL مجزا با تراکنشهای
|
||
باز همزمان استفاده کن. `assertSame(1, …)` تنها معیار قبولی است.
|
||
|
||
## ۲. به `assignment` کلاینت اعتماد نکن
|
||
|
||
```php
|
||
// ❌ فاجعه
|
||
foreach ($request['assignment'] as $role => $resourceUuid) {
|
||
$occupancy->setResource($this->resourceRepo->findByUuid($resourceUuid));
|
||
}
|
||
|
||
// ✅
|
||
$plan = $this->planBuilder->build(…); // برنامه را خودت بساز
|
||
foreach ($plan->requirements() as $req) {
|
||
$chosen = $request['assignment'][$req->key()] ?? null;
|
||
if ($chosen === null || !in_array($chosen, $req->candidateUuids, true)) {
|
||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'منبع انتخابی برای این خدمت معتبر نیست', 422);
|
||
}
|
||
}
|
||
```
|
||
|
||
سه نشتی ثبتشده در `docs/architecture/tenancy.md` همگی از همین شکل بودند: uuid از بدنه
|
||
آمد و کسی محیطش را نسنجید. اینجا حتی سنجیدن محیط کافی نیست — منبع باید **کاندید همان
|
||
نیازمندی** باشد.
|
||
|
||
## ۳. ترتیب نوشتن سطلها — جلوگیری از deadlock
|
||
|
||
دو تراکنش که سطلها را به ترتیب متفاوت `INSERT` کنند، deadlock میسازند.
|
||
قاعده: **همیشه مرتب بر اساس `(resource_id, bucket, unit_index)` صعودی**.
|
||
|
||
```php
|
||
$rows = $this->buildSlotRows($assignment, $segments);
|
||
usort($rows, fn($a, $b) => [$a['resource_id'], $a['bucket'], $a['unit_index']]
|
||
<=> [$b['resource_id'], $b['bucket'], $b['unit_index']]);
|
||
foreach ($rows as $row) { $this->insertOrFail($row); }
|
||
```
|
||
|
||
بدون این، تست همزمانی گاهی `Deadlock found when trying to get lock` میدهد و کسی
|
||
فکر میکند تست flaky است.
|
||
|
||
## ۴. `unit_index` — جستجوی خطی، نه `SELECT`
|
||
|
||
```php
|
||
for ($idx = 0; $idx < $capacity; $idx++) {
|
||
try { $this->conn->insert(…, ['unit_index' => $idx]); return $idx; }
|
||
catch (UniqueConstraintViolationException) { continue; }
|
||
}
|
||
throw new SlotTakenException();
|
||
```
|
||
|
||
وسوسه میشود اول `SELECT` بزنی که کدام index آزاد است. نکن — بین `SELECT` و `INSERT`
|
||
پنجرهٔ رقابت باز میشود و کل مزیت این طراحی از بین میرود. با `capacity` معمول (۱ تا ۵)
|
||
حلقه ارزان است.
|
||
|
||
## ۵. `setup/cleanup` در بازهٔ اشغال، نه بخش
|
||
|
||
```php
|
||
$occStart = $segment->getStartAt() - $resource->getSetupMinutes() * 60;
|
||
$occEnd = $segment->getEndAt() + $resource->getCleanupMinutes() * 60;
|
||
```
|
||
|
||
و `appointment_segments.start_at/end_at` بدون آنها. بیمار ساعت ۱۰:۰۰ میآید؛ یونیت از
|
||
۹:۵۵ اشغال است. دو عدد متفاوت، دو ستون متفاوت.
|
||
|
||
## ۶. رویدادها بعد از commit
|
||
|
||
```php
|
||
// ❌ اگر تراکنش rollback شود، پیامک رفته و نوبتی وجود ندارد
|
||
$this->bus->dispatch(new AppointmentBooked($appointment));
|
||
$this->em->flush();
|
||
|
||
// ✅
|
||
$this->bus->dispatch(
|
||
(new Envelope(new AppointmentBooked($appointment->getUuid())))
|
||
->with(new DispatchAfterCurrentBusStamp())
|
||
);
|
||
```
|
||
|
||
و در payload رویداد **uuid** بفرست، نه entity — تسک ۱۴ همین قرارداد را دارد.
|
||
|
||
## ۷. لغو = آزادسازی، نه حذف
|
||
|
||
```php
|
||
// همهٔ ردیفهای اشغال نوبت
|
||
$occupancy->setStatus(ResourceOccupancy::STATUS_RELEASED);
|
||
// ولی ردیفهای سطل حذف فیزیکی میشوند تا جا آزاد شود
|
||
$this->conn->delete('resource_occupancy_slot', ['occupancy_id' => $occupancy->getId()]);
|
||
```
|
||
|
||
`resource_occupancy` برای آدیت و گزارش بهرهوری میماند. `resource_occupancy_slot` فقط
|
||
مکانیزم قفل است و ردیف مرده در آن یعنی ظرفیت مسدود.
|
||
|
||
## ۸. `reschedule` اتمی
|
||
|
||
```php
|
||
$this->em->wrapInTransaction(function () use ($appointment, $newStart) {
|
||
$newHold = $this->holdService->hold(…); // ۱ اگر شکست بخورد، همهچیز rollback
|
||
$this->occupancyWriter->release($appointment); // ۲
|
||
$this->transition($appointment, STATUS_RESCHEDULED);// ۳
|
||
$this->linkReschedule($appointment, $newHold); // ۴
|
||
});
|
||
```
|
||
|
||
ترتیب مهم است: **اول hold جدید، بعد آزادسازی قدیم**. برعکسش یعنی اگر hold جدید شکست
|
||
بخورد، بیمار هم نوبت قدیم را از دست داده هم جدید نگرفته.
|
||
|
||
## ۹. edge case ها
|
||
|
||
| حالت | رفتار درست |
|
||
|---|---|
|
||
| hold روی نوبتی که همان لحظه cron منقضیاش کرد | `409 ERR_HOLD_EXPIRED` — نه ۵۰۰ |
|
||
| `confirm` دوباره روی همان hold | idempotent: نوبت قبلاً `confirmed` → همان را برگردان، نه خطا |
|
||
| منبع بین hold و confirm غیرفعال شد | `confirm` موفق — اشغال گرفته شده و کلینیک باید دستی حل کند. لاگ هشدار |
|
||
| نوبت `is_reserve=true` (لیست رزرو موجود) | هیچ ردیف اشغالی نمیسازد — روزی است، نه ساعتی |
|
||
| ظرفیت ۳، سه hold، یکی منقضی | سطل آزاد میشود و چهارمی میتواند بگیرد |
|
||
| بازهٔ اشغال دقیقاً روی مرز سطل | `intdiv($end - 1, 300)` — تست مرزی اجباری |
|
||
| نوبت گذشته | hold روی زمان گذشته → `422` |
|
||
| `capacity` منبع بعد از ثبت کم شد | اشغالهای موجود میمانند (over-subscription موقت)، جدید رد میشود. در پنل هشدار |
|
||
| دو بخش مجاور یک نوبت روی یک منبع | دو ردیف اشغال، سطلهای متمایز (به لطف `-1`) |
|
||
|
||
## ۱۰. تست
|
||
|
||
```
|
||
tests/Appointment/Booking/OccupancyWriterTest.php
|
||
- سطلهای [10:00, 10:30) و [10:30, 11:00) تداخل ندارند
|
||
- capacity=3 → سه unit_index، چهارمی SlotTakenException
|
||
- ترتیب مرتب INSERT
|
||
tests/Appointment/ConcurrentHoldTest.php ← ⭐ اجباری
|
||
- دو تراکنش موازی → دقیقاً یکی موفق
|
||
tests/Appointment/HoldLifecycleTest.php
|
||
- hold → زمان از availability حذف میشود
|
||
- انقضا → دوباره ظاهر میشود
|
||
- DELETE hold → فوری آزاد
|
||
tests/Appointment/BookingConfirmTest.php
|
||
- confirm موفق → همهٔ اشغالها booked و expires_at null
|
||
- confirm hold دیگری → 404
|
||
- confirm منقضی → 409
|
||
- confirm دوباره → idempotent
|
||
tests/Appointment/CapacityReleaseIntegrationTest.php ← ⭐
|
||
- بعد از ثبت نوبت لیزر، اپراتور در بازهٔ انتظار هیچ ردیف اشغالی ندارد
|
||
- و جستجوی بیمار دوم آن بازه را پیدا میکند
|
||
tests/Appointment/RescheduleTest.php
|
||
- شکست hold جدید → نوبت قدیم دستنخورده
|
||
tests/Appointment/OccupancyBackfillTest.php
|
||
- نوبتهای موجود ردیف اشغال میگیرند؛ idempotent
|
||
tests/Appointment/LegacyBookingUnchangedTest.php
|
||
- POST /api/v1/appointment قدیمی دقیقاً مثل قبل کار کند
|
||
tests/Appointment/BookingTenantTest.php ← موجود، باید سبز بماند
|
||
```
|
||
|
||
## ۱۱. مستندات
|
||
|
||
`docs/api/appointment-booking.md` بساز. حتماً بنویس:
|
||
- گرانولاریتی ۵ دقیقهای و محدودیتش
|
||
- قرارداد `hold_uuid` و TTL
|
||
- کدهای خطا: `ERR_SLOT_TAKEN` (409)، `ERR_HOLD_EXPIRED` (409)
|
||
- در `ErrorCodes.php` هر دو کد با پیام فارسی ثبت شوند
|
||
|
||
`docs/architecture/` یک سند جدید `booking-concurrency.md` بگیرد که راهحل سطل زمانی و
|
||
دلیل رد گزینههای دیگر را ثبت کند — این تصمیمی است که شش ماه بعد کسی زیر سؤال میبرد.
|