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.
This commit is contained in:
hamed
2026-07-30 11:43:58 +03:30
parent 1d338503c8
commit 021d0eb6b2
62 changed files with 8098 additions and 0 deletions
@@ -0,0 +1,182 @@
# معماری — تسک ۰۷
## ساختار فایل
```
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 هم مانع نمی‌شود.
@@ -0,0 +1,145 @@
# دیتابیس — تسک ۰۷
## `resource_occupancy` — مهم‌ترین جدول سیستم
| ستون | نوع | توضیح |
|---|---|---|
| `id` | BIGINT PK AI | BIGINT چون پرحجم‌ترین جدول می‌شود |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `resource_id` | INT NOT NULL | FK → `clinic_resources.id` ON DELETE RESTRICT |
| `appointment_id` | INT NULL | FK → `appointments.id` ON DELETE CASCADE؛ NULL = مسدودسازی دستی |
| `appointment_segment_id` | INT NULL | FK ON DELETE CASCADE |
| `start_at` | INT NOT NULL | **شامل `setup_minutes` منبع** |
| `end_at` | INT NOT NULL | **شامل `cleanup_minutes` منبع** |
| `units` | SMALLINT NOT NULL DEFAULT 1 | |
| `occupancy_kind` | VARCHAR(10) NOT NULL | `exclusive`\|`shared`\|`passive` |
| `status` | VARCHAR(10) NOT NULL | `hold`\|`booked`\|`released` |
| `expires_at` | INT NULL | فقط برای `hold` |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_occ_resource_range (resource_id, start_at, end_at, status) -- کوئری داغ تسک ۰۶
KEY idx_occ_tenant_range (entity_type, entity_id, start_at) -- لیست‌های پنل
KEY idx_occ_appointment (appointment_id)
KEY idx_occ_expiry (status, expires_at) -- cron انقضا
```
## `resource_occupancy_slot` — تضمین یکتایی
```sql
CREATE TABLE resource_occupancy_slot (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resource_id INT NOT NULL,
bucket INT NOT NULL, -- floor(timestamp / 300)
unit_index SMALLINT NOT NULL, -- 0 .. capacity-1
occupancy_id BIGINT NOT NULL,
UNIQUE KEY uniq_occ_slot (resource_id, bucket, unit_index), -- ← کل تضمین اینجاست
KEY idx_occ_slot_occupancy (occupancy_id),
CONSTRAINT fk_occ_slot_occupancy FOREIGN KEY (occupancy_id)
REFERENCES resource_occupancy(id) ON DELETE CASCADE
) ENGINE=InnoDB;
```
`BUCKET_SECONDS = 300` در `OccupancyWriter::BUCKET_SECONDS` ثابت است. تغییرش بعد از
تولید داده، migration کامل می‌خواهد — در کد کامنت هشدار بگذار.
سطل‌های یک بازه:
```php
// [start, end) نیم‌باز → سطل آخر شامل نمی‌شود اگر دقیقاً روی مرز باشد
$first = intdiv($start, self::BUCKET_SECONDS);
$last = intdiv($end - 1, self::BUCKET_SECONDS);
```
بدون `-1` نوبت ۱۰:۰۰-۱۰:۳۰ و نوبت ۱۰:۳۰-۱۱:۰۰ سطل مشترک می‌گیرند و دومی بی‌دلیل رد می‌شود.
## `appointment_segments`
| ستون | نوع | توضیح |
|---|---|---|
| `id` | INT PK AI | |
| `uuid` | VARCHAR(36) UNIQUE | |
| `entity_type` / `entity_id` | VARCHAR(10) / INT NOT NULL | |
| `appointment_id` | INT NOT NULL | FK ON DELETE CASCADE |
| `sequence` | SMALLINT NOT NULL | |
| `name` | VARCHAR(150) NOT NULL | snapshot نام بخش در لحظهٔ ثبت |
| `segment_type` | VARCHAR(40) NOT NULL | |
| `start_at` | INT NOT NULL | مطلق |
| `end_at` | INT NOT NULL | |
| `patient_present` | TINYINT(1) NOT NULL DEFAULT 1 | |
| `created_at` | INT NOT NULL | |
```sql
KEY idx_appt_seg_appointment (appointment_id, sequence)
KEY idx_appt_seg_tenant (entity_type, entity_id, start_at)
```
`name` و `segment_type` عمداً کپی می‌شوند نه FK: قانون پنجم مستند — «هر چیزی که ثبت شد
باید همان‌طور بماند». اگر کلینیک فردا الگو را عوض کند، نوبت دیروز نباید تغییر معنا دهد.
## تغییر `appointments`
```sql
ALTER TABLE appointments
ADD COLUMN branch_id INT NULL,
ADD COLUMN plan_total_minutes SMALLINT NULL,
ADD COLUMN patient_facing_minutes SMALLINT NULL,
ADD CONSTRAINT fk_appointments_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE SET NULL,
ADD KEY idx_appointments_branch (branch_id, slot_start);
```
`slot_start` / `slot_end` **می‌مانند** و در حالت `resource` برابر شروع اولین بخش و پایان
آخرین بخش‌اند. دلیل: `AppointmentRepository`، لیست‌های پنل، سایت عمومی و اپ دسکتاپ همه
روی این دو ستون کوئری می‌زنند. برداشتنشان یعنی بازنویسی همه‌جا.
وضعیت جدید:
```php
public const STATUS_RESCHEDULED = 'rescheduled';
// ALLOWED_TRANSITIONS: confirmed → rescheduled ; pending → rescheduled
```
## Migration
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```
⚠️ **دو ایندکس را دستی در migration بنویس**`doctrine:migrations:diff` ترتیب ستون‌های
ایندکس ترکیبی را گاهی متفاوت تولید می‌کند و ترتیب اینجا حیاتی است
(`resource_id` اول در `idx_occ_resource_range`).
## backfill نوبت‌های موجود
```bash
ddev exec php bin/console app:occupancy:backfill --force
```
برای هر نوبت `pending`/`confirmed` آینده در حالت `slot`/`service`:
- یک `appointment_segment` واحد بساز (کل بازه)
- یک `resource_occupancy` روی منبع `type=doctor` همان پزشک
- ردیف‌های `resource_occupancy_slot` متناظر
اگر منبع `type=doctor` وجود ندارد (backfill تسک ۰۲ اجرا نشده)، آن نوبت رد شود و در
خروجی گزارش شود — نه خطا.
بدون این backfill، اولین رزرو در حالت جدید ممکن است روی نوبت قدیمی بنشیند.
## نگهداشت
```bash
ddev exec php bin/console app:occupancy:prune --older-than=90d --force
```
ردیف‌های `resource_occupancy_slot` مربوط به بازه‌های گذشته را حذف می‌کند.
`resource_occupancy` می‌ماند (آدیت و گزارش بهره‌وری تسک ۱۴).
## طبقه‌بندی tenant
| جدول | وضعیت |
|---|---|
| `resource_occupancy` | جفت tenant |
| `appointment_segments` | جفت tenant (uuid ممکن است از request بیاید) |
| `resource_occupancy_slot` | `AGGREGATE_CHILDREN` → ریشه `ResourceOccupancy`**هرگز مستقیم کوئری نشود** |
@@ -0,0 +1,183 @@
# نکات پیاده‌سازی — تسک ۰۷
## ۱. تست همزمانی واقعی، نه 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` بگیرد که راه‌حل سطل زمانی و
دلیل رد گزینه‌های دیگر را ثبت کند — این تصمیمی است که شش ماه بعد کسی زیر سؤال می‌برد.
@@ -0,0 +1,81 @@
# تسک ۰۷ — رزرو موقت و ثبت نهایی چندمنبعی
**فاز:** ۱ (هسته) · **وابستگی:** ۰۶ · **زمان:** ۱۶-۲۰ ساعت
---
## هدف
مستند بند ۱۱ و قانون سوم جمع‌بندی: «جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد».
سه مرحلهٔ `جستجو → رزرو موقت → ثبت نهایی` روی **چند منبع** پیاده شود، با تضمین یکتایی
در سطح دیتابیس.
## وضعیت فعلی — نقطهٔ قوت پروژه
```php
// src/Appointment/Entity/Appointment.php
public const PAYMENT_TTL = 900;
#[ORM\Column(name: 'active_slot_key', length: 64, nullable: true, unique: true)]
private ?string $activeSlotKey = null; // "{doctorId}:{slotStart}" یا NULL
private function refreshActiveSlotKey(): void {
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
: null;
}
```
سه مرحله و تضمین دیتابیسی **از قبل درست پیاده شده‌اند**. محدودیت: کلید فقط
`doctor + slot_start` است و هیچ منبع دیگری را نمی‌پوشاند، و مدل «یک ردیف = یک بازهٔ پیوسته»
است.
## دامنه
**هست:**
- `resource_occupancy` — تنها مرجع حقیقت اشغال منابع
- `appointment_segments` — بخش‌های نوبت ثبت‌شده
- `HoldService` — رزرو موقت چندمنبعی با TTL
- `BookingService` — ثبت نهایی اتمی
- تضمین یکتایی در MariaDB (بدون `EXCLUDE` — راه‌حل «سطل زمانی»)
- انقضای خودکار hold ها (توسعهٔ `ExpireAppointmentsHandler` موجود)
- وضعیت `rescheduled` و رویداد جابه‌جایی
**نیست:** قیمت‌گذاری تفکیک‌شده (تسک ۰۸)، سیاست لغو و جریمه (تسک ۱۳).
## Endpoint ها
| متد | مسیر | توضیح |
|---|---|---|
| POST | `/api/v1/appointment-hold` | رزرو موقت یک زمان + منابعش |
| DELETE | `/api/v1/appointment-hold/{uuid}` | آزادسازی زودهنگام |
| POST | `/api/v1/appointment-confirm` | ثبت نهایی از یک hold معتبر |
| POST | `/api/v1/appointment/{uuid}/reschedule` | جابه‌جایی (hold جدید + آزادسازی قدیم، اتمی) |
## معیار پذیرش
- ✅ موفق: `POST /appointment-hold` با زمان و `assignment` معتبر → `201` با
`hold_uuid` و `expires_at`؛ ردیف‌های `resource_occupancy` با `status='hold'` برای
**هر بخش × هر منبع** ثبت می‌شوند.
- ✅ موفق: بلافاصله بعد از hold، `POST /appointment-availability` همان زمان را **برنمی‌گرداند**.
- ✅ موفق: `POST /appointment-confirm``200`، وضعیت `confirmed`، همهٔ ردیف‌های اشغال
`status='booked'` و `expires_at = NULL`.
- ✅ موفق (**تست همزمانی — اصلی‌ترین**): دو درخواست hold هم‌زمان روی همان منبع و بازه →
دقیقاً **یکی** `201` و دیگری `409` با `ERR_SLOT_TAKEN`. تست باید با تراکنش واقعی
موازی اجرا شود، نه mock.
- ✅ موفق: hold منقضی‌شده → `POST /appointment-confirm` با `409 ERR_HOLD_EXPIRED` و آن
زمان دوباره در جستجو ظاهر می‌شود.
- ✅ موفق: آزادسازی ظرفیت حفظ می‌شود — اپراتور در بخش انتظار ردیف اشغال **ندارد**.
- ❌ خطا: `confirm` با hold متعلق به کاربر دیگر → `404`.
- ❌ خطا: hold با منبعی که در `assignment` نیست ولی نیازمندی دارد → `422`.
- ⚠️ مرزی: منبع با `capacity=3` → سه hold هم‌زمان موفق، چهارمی `409`.
- ⚠️ مرزی: لغو نوبت → همهٔ ردیف‌های اشغالش آزاد (`status='released'`)، نه حذف فیزیکی.
- ⚠️ مرزی: `reschedule` که hold جدیدش شکست بخورد → نوبت قدیمی **دست‌نخورده** بماند.
- ⚠️ مرزی: نوبت‌های حالت `slot`/`service``active_slot_key` قدیمی همچنان کار می‌کند و
ردیف اشغال هم برایشان ساخته می‌شود (تور ایمنی دوگانه).
## خروجی
- `src/Appointment/Booking/`
- توسعهٔ `Appointment` entity با `segments` و رابطهٔ اشغال
- `docs/api/appointment-booking.md` + به‌روزرسانی `docs/api/appointment.md`
- تست همزمانی واقعی
@@ -0,0 +1,150 @@
# جریان کاربری — تسک ۰۷
## الف) مسیر موفق — بیمار از سایت عمومی
```
[تسک ۰۶] بیمار ساعت ۰۹:۰۰ را انتخاب می‌کند
POST /api/v1/appointment-hold
{ "doctor_uuid":"…", "branch_uuid":"…", "service_item_uuid":"…",
"option_uuids":["…"], "start": 1754…, "assignment": { "operator":"…", "room":"…", "device":"…" } }
├─ سرور: برنامه را دوباره می‌سازد (به assignment اعتماد نمی‌کند)
├─ نوبت pending با expires_at = now + 900
├─ appointment_segments × ۵
└─ resource_occupancy × (بخش × منبع) — بخش انتظار فقط اتاق
201 { "hold_uuid":"…", "expires_at": 1754…, "total_price_rials": … }
│ ⏱ تایمر ۱۵ دقیقه‌ای در UI: «۱۴:۵۹ برای تکمیل رزرو»
پرداخت بیعانه (اگر deposit_required) → درگاه → بازگشت
POST /api/v1/appointment-confirm { "hold_uuid":"…" }
├─ ۱ hold معتبر است؟
├─ ۲ قوانین صلاحیت و فاصله (تسک ۰۹)
├─ ۳ وضعیت → confirmed
├─ ۴ اشغال‌ها hold → booked
├─ ۵ snapshot قیمت (تسک ۰۸)
└─ ۶ رویداد AppointmentBooked (بعد از commit)
200 { "appointment_uuid":"…", "status":"confirmed" }
پیامک تأییدیه (از راه رویداد، async)
```
---
## ب) رقابت روی ساعت پرتقاضا
```
بیمار الف بیمار ب
│ ۰۹:۰۰ را می‌بیند │ ۰۹:۰۰ را می‌بیند
│ │
▼ POST /appointment-hold ▼ POST /appointment-hold
│ │
│ INSERT slot (res=7,b=…,u=0) ✓ │ INSERT slot (res=7,b=…,u=0) ✗ duplicate
▼ ▼
201 hold_uuid 409 ERR_SLOT_TAKEN
UI: «این ساعت همین لحظه رزرو شد.»
+ لیست به‌روزشدهٔ وقت‌های نزدیک
(خودکار، بدون کلیک دوباره)
```
پیام «این ساعت همین لحظه رزرو شد» + پیشنهاد جایگزین، همان کاری است که مستند بند ۱۷
برای ریسک «رقابت روی ساعت‌های پرتقاضا» می‌خواهد. `409` خالی بدون جایگزین یعنی بیمار می‌رود.
---
## ج) hold منقضی می‌شود
```
hold ساخته شد ─── ۱۵ دقیقه ───▶ منقضی
┌─────────────────────────┴──────────────────────────┐
│ │
cron هر دقیقه یا: بیمار confirm می‌زند
ExpireAppointmentsHandler │
│ ▼
├─ status → expired 409 ERR_HOLD_EXPIRED
├─ occupancy → released │
└─ occupancy_slot → DELETE ▼
▼ UI: «زمان رزرو شما به پایان رسید»
زمان دوباره در جستجو ظاهر می‌شود + بازگشت به لیست وقت‌ها
```
نکته: حتی پیش از اجرای cron، `hasRoom` تسک ۰۶ شرط `expires_at > now` را دارد، پس
hold مردهٔ چند ثانیه‌ای هم مانع کسی نمی‌شود. cron فقط تمیزکاری است.
---
## د) منشی نوبت را جابه‌جا می‌کند
```
پنل › نوبت‌ها › جزئیات نوبت › «جابه‌جایی»
انتخاب تاریخ/ساعت جدید (همان UI تسک ۰۶، با forManagement=true)
POST /api/v1/appointment/{uuid}/reschedule { "start": … , "assignment": {…} }
├─ ۱ hold جدید ساخته می‌شود ← اگر شکست: rollback کامل، نوبت قدیم سالم
├─ ۲ اشغال‌های قدیم released
├─ ۳ نوبت قدیم → rescheduled
└─ ۴ لینک قدیم ↔ جدید در appointment_events
200 { "new_appointment_uuid": "…" }
پیامک اطلاع‌رسانی جابه‌جایی
```
اگر hold جدید `409` بدهد:
```
422 { "errors": [{ "code":"ERR_SLOT_TAKEN",
"message":"زمان جدید در دسترس نیست. نوبت فعلی تغییری نکرد." }] }
```
جملهٔ دوم پیام اجباری است — منشی باید بداند وضعیت فعلی امن است و لازم نیست چیزی را
درست کند.
---
## ه) لغو نوبت
```
لغو (بیمار یا پزشک یا منشی)
├─ status → cancelled_by_user / cancelled_by_doctor
├─ active_slot_key → NULL (مکانیزم موجود، دست‌نخورده)
├─ resource_occupancy → released
└─ resource_occupancy_slot → DELETE
ظرفیت فوری آزاد می‌شود
[تسک ۱۳] لیست انتظار همان بازه اطلاع می‌گیرد
```
---
## و) مسدودسازی دستی یک منبع
حالت خاصی که `appointment_id = NULL` را توضیح می‌دهد:
```
پنل › منابع › دستگاه کندلا ۲ › «مسدودسازی بازه»
تاریخ/ساعت + دلیل: «سرویس دوره‌ای»
یک ردیف resource_occupancy با appointment_id=NULL و status='booked'
(یا معادلاً یک resource_exception از تسک ۰۳ — هر دو کار می‌کنند)
```
**تصمیم:** مسدودسازی **بلندمدت و تکرارشونده**`resource_exception` (تسک ۰۳).
مسدودسازی **موردی و کوتاه**`resource_occupancy` با `appointment_id=NULL`.
دلیل: دومی در همان ایندکس داغ می‌نشیند و در `OccupancyIndex` بدون کد اضافه دیده می‌شود.
این تفکیک را در `docs/api/appointment-booking.md` بنویس، وگرنه دو راه انجام یک کار
گیج‌کننده می‌شود.