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:
@@ -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` بنویس، وگرنه دو راه انجام یک کار
|
||||
گیجکننده میشود.
|
||||
Reference in New Issue
Block a user