Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:
- AppointmentCompleted fires from both status-change routes, after the row is
saved. A rejected transition or a version conflict leaves no event; otherwise
the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
confirm plus a cancel, and a consumer that only hears the cancel messages a
patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
as audible as capacity going away, or the resource reads as permanently taken.
Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.
Panel work that these paths made reachable:
- Cancelling from the appointment page now goes through the policy-aware
endpoint and shows the penalty preview before the confirm, so the operator
does not discover the patient's penalty after the fact. The cancellation
service writes the timeline entry itself and accepts a reason, which that
path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
hold steps are identical and only the final step differs. The doctor picker
is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
list is not an error, it means the appointment is slot-based, and that is
exactly what gates the resource-mode reschedule button.
AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
10 KiB
Markdown
223 lines
10 KiB
Markdown
# Appointment Booking API — رزرو موقت و ثبت نهایی چندمنبعی
|
|
|
|
> **Base:** `/api/v1` · **Auth:** JWT
|
|
> دنبالهٔ [appointment-availability.md](appointment-availability.md).
|
|
|
|
---
|
|
|
|
## سه مرحله
|
|
|
|
```
|
|
جستجو → رزرو موقت (hold) → ثبت نهایی (confirm)
|
|
```
|
|
|
|
مرحلهٔ میانی لازم است چون بین دیدن یک زمان و ثبتش، فاصله هست: بیمار فرم پر میکند،
|
|
پرداخت میکند، مردد میشود. بدون رزرو موقت، همان زمان به چند نفر پیشنهاد میشود و
|
|
آخری خطا میگیرد.
|
|
|
|
## چرا تضمین در دیتابیس است، نه در کد
|
|
|
|
قانون سوم جمعبندی مستند: **«جلوگیری از رزرو تکراری کار دیتابیس است، نه کار کد.»**
|
|
|
|
هر بررسیِ «آیا آزاد است؟» در PHP یک پنجرهٔ مسابقه بین خواندن و نوشتن دارد؛ دو درخواست
|
|
همزمان هر دو «آزاد» میبینند و هر دو مینویسند.
|
|
|
|
MariaDB قید `EXCLUDE` بازهای ندارد، پس هر بازهٔ اشغال به **سطلهای ثابت پنجدقیقهای**
|
|
شکسته میشود و کلید یکتای زیر تداخل را غیرممکن میکند:
|
|
|
|
```sql
|
|
UNIQUE (resource_id, bucket_at, seat)
|
|
```
|
|
|
|
کد فقط `INSERT` میزند؛ اگر دیتابیس ردش کرد، همان یعنی «گرفته شده».
|
|
|
|
**`seat` ظرفیت را بیان میکند.** اتاق سهتخته صندلیهای ۰ تا ۲ دارد؛ تلاش از صندلی ۰
|
|
شروع میشود و با هر برخورد یکی جلو میرود. چهارمین رزروِ همزمان جایی برای نشستن پیدا
|
|
نمیکند و `409` میگیرد. شمردن ظرفیت در PHP دقیقاً همان مسابقهای را میساخت که این
|
|
طراحی حذفش میکند.
|
|
|
|
---
|
|
|
|
## `POST /api/v1/appointment-hold`
|
|
|
|
```json
|
|
{
|
|
"service_uuid": "…",
|
|
"branch_uuid": "…",
|
|
"start": 1785562200,
|
|
"item_uuids": ["…"],
|
|
"patient_gender": "female",
|
|
"assignment": { "room": ["…"], "operator": ["…"], "device": ["…"] }
|
|
}
|
|
```
|
|
|
|
`assignment` همان چیزی است که جستجوی وقت پیشنهاد داده. **هر نیازمندی باید منبع داشته
|
|
باشد**؛ وگرنه `422` — رزروی که نصف منابع لازم را بگیرد، هنگام حضور بیمار کم میآورد.
|
|
|
|
**۲۰۱:**
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"hold_uuid": "…",
|
|
"starts_at": 1785562200,
|
|
"ends_at": 1785565800,
|
|
"expires_at": 1785563100,
|
|
"confirmed": false,
|
|
"assignment": { "room": [{ "uuid": "…", "name": "اتاق ۲" }] }
|
|
}
|
|
}
|
|
```
|
|
|
|
مهلت **۹۰۰ ثانیه** است — همان مهلتی که پرداخت نوبت دارد؛ دو عدد متفاوت یعنی دو حقیقت
|
|
متفاوت.
|
|
|
|
بلافاصله پس از رزرو، `POST /appointment-availability` آن زمان را دیگر برنمیگرداند.
|
|
|
|
**۴۰۹ `ERR_SLOT_TAKEN`:** منبع در آن بازه ظرفیت خالی ندارد.
|
|
**۴۲۲:** نبودِ منبع برای یک نقش · `assignment` خالی.
|
|
**۴۰۴:** سرویس، شعبه یا منبع محیط دیگر.
|
|
|
|
> **رزرو نیمهکاره نمیماند.** اگر منبع دوم جا نداشت، منبع اول هم آزاد میشود و خودِ
|
|
> رزرو حذف — وگرنه منبعی قفل میماند که هرگز نوبتی رویش ثبت نمیشود.
|
|
|
|
## `DELETE /api/v1/appointment-hold/{uuid}`
|
|
|
|
آزادسازی زودهنگام؛ آن زمان بلافاصله دوباره در جستجو ظاهر میشود.
|
|
رزروی که ثبت نهایی شده آزاد نمیشود (`422`).
|
|
|
|
## `POST /api/v1/appointment-confirm`
|
|
|
|
```json
|
|
{ "hold_uuid": "…", "doctor_uuid": "…", "patient_uuid": "…" }
|
|
```
|
|
|
|
`patient_uuid` اختیاری است؛ نبودش یعنی خودِ کاربر (منشی میتواند برای دیگری ثبت کند).
|
|
|
|
**۲۰۰:** `appointment_uuid` + بازه + تخصیص.
|
|
|
|
تبدیل `hold → booked` **هیچ منبعی را دوباره نمیگیرد**: صندلیها از لحظهٔ رزرو موقت
|
|
گرفته شدهاند و اینجا فقط برچسبشان عوض میشود. اگر ثبت نهایی دوباره رزرو میکرد، همان
|
|
پنجرهٔ مسابقهای که رزرو موقت حذفش کرده بود برمیگشت.
|
|
|
|
**۴۰۹ `ERR_HOLD_EXPIRED`** روی رزروِ منقضی · **۴۰۹ `ERR_SLOT_TAKEN`** روی رزروِ
|
|
قبلاً ثبتشده · **۴۰۴** روی رزرو کاربر دیگر (نه ۴۰۳ — وجودش نباید لو برود).
|
|
|
|
## `POST /api/v1/appointment/{uuid}/rebook`
|
|
|
|
جابهجایی: **اول** رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست
|
|
بخورد، نوبت قدیمی دستنخورده میماند و بیمار بینوبت نمیشود.
|
|
|
|
```json
|
|
{ "hold_uuid": "5e0e…" }
|
|
```
|
|
|
|
پاسخ: `appointment_uuid` · `released_intervals` (تعداد اشغال آزادشدهٔ زمان قبلی) ·
|
|
`starts_at` (زمان تازه).
|
|
|
|
سه رویداد دامنه ثبت میشود، نه یکی: `AppointmentBooked`، `AppointmentCancelled` و
|
|
`AppointmentRescheduled`. سومی همان چیزی است که دو تای اول را به هم وصل میکند؛ بدون آن،
|
|
مصرفکنندهای که فقط لغو را میشنود برای بیماری که هنوز نوبت دارد پیام لغو میفرستد.
|
|
|
|
در پنل، همین مسیر با `/admin/resource-booking?rebook={uuid}` باز میشود: همان جستجو و
|
|
رزرو موقت، فقط گام آخرش جابهجایی است.
|
|
|
|
## `GET /api/v1/appointment/{uuid}/segments`
|
|
|
|
بخشهای **ثبتشدهٔ** نوبت — عکسِ لحظهٔ رزرو، نه الگوی امروزِ خدمت. تغییر بعدیِ الگو این
|
|
فهرست را عوض نمیکند.
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{ "sequence": 1, "name": "بیحسی", "starts_at": 1811232000, "ends_at": 1811232300,
|
|
"duration_minutes": 5, "patient_present": true },
|
|
{ "sequence": 2, "name": "انتظار", "starts_at": 1811232300, "ends_at": 1811234100,
|
|
"duration_minutes": 30, "patient_present": false }
|
|
]
|
|
}
|
|
```
|
|
|
|
**فهرست خالی خطا نیست** و یعنی نوبت اسلاتی است. پنل با همین تفاوت تصمیم میگیرد دکمهٔ
|
|
جابهجایی منبعمحور را نشان بدهد یا نه.
|
|
|
|
**۴۰۴** روی نوبت محیط دیگر.
|
|
|
|
---
|
|
|
|
## اشغال: یک ردیف per (بخش × منبع)
|
|
|
|
نه یکی per نوبت. همین ریزدانگی ظرفیت آزاد میکند.
|
|
|
|
مثال واقعی (و تستِ مرجع): نوبت ۵۵ دقیقهای با بخشهای بیحسی ۵ · انتظار ۳۰ · لیزر ۲۰:
|
|
|
|
| منبع | تعداد ردیف اشغال |
|
|
|---|---|
|
|
| اتاق | **۳** — هر سه بخش |
|
|
| اپراتور | **۲** — فقط بیحسی و لیزر |
|
|
|
|
اپراتور در بازهٔ انتظار **هیچ ردیفی ندارد** و برای بیمار دیگری آزاد است.
|
|
|
|
بازهٔ ثبتشده گستردهتر از بازهٔ بخش است: زمان آمادهسازی و تمیزکاری منبع هم درونش
|
|
میآید.
|
|
|
|
### `status`
|
|
|
|
| مقدار | یعنی |
|
|
|---|---|
|
|
| `hold` | رزرو موقت، تا پایان مهلت |
|
|
| `booked` | نوبت قطعی |
|
|
| `released` | لغو یا منقضی |
|
|
|
|
**لغو، ردیف را حذف فیزیکی نمیکند.** تاریخچهٔ اینکه چه منبعی کِی گرفته شده بود ورودی
|
|
گزارش بهرهوری است؛ حذفش یعنی پاک کردن همان چیزی که قرار است اندازه بگیریم. ولی
|
|
سطلهای یکتایی حذف میشوند، وگرنه آن زمان برای همیشه قفل میماند.
|
|
|
|
---
|
|
|
|
## تور ایمنی دوگانه
|
|
|
|
نوبت با همان سازندهٔ موجود ساخته میشود، پس `active_slot_key`، رویدادها و مسیر پرداخت
|
|
دقیقاً مثل قبل کار میکنند. اشغال چندمنبعی **کنار** آن مینشیند، نه بهجایش.
|
|
|
|
---
|
|
|
|
## تستها
|
|
|
|
```bash
|
|
ddev exec php bin/phpunit tests/Appointment/HoldAndBookTest.php # ۱۲ تست
|
|
```
|
|
|
|
دو تست از همه مهمترند: رزرو دوم روی همان منبع و بازه که `409` میگیرد، و تستی که
|
|
**مستقیم روی یک اتصال جدا** ردیف تکراری مینویسد و انتظار نقض کلید یکتا دارد — اگر آن
|
|
یکی بشکند، یعنی تضمین فقط در کد بوده است.
|
|
|
|
---
|
|
|
|
## قوانین وابسته به بیمار
|
|
|
|
`POST /api/v1/appointment-hold` پیش از گرفتن صندلی دو دسته را اجرا میکند:
|
|
|
|
| دسته | خطا | معنی |
|
|
|---|---|---|
|
|
| `eligibility` | `ERR_VALIDATION_001` / `422` | قانونی این بیمار را برای این خدمت رد کرده |
|
|
| `eligibility` (`require_flag`) | `ERR_VALIDATION_002` / `422` | پرچمی مثل `has_parental_consent` در بدنه نیامده |
|
|
| `spacing` | `ERR_VALIDATION_001` / `422` | فاصله تا نوبت قبلیِ همان دستهٔ کاتالوگ کمتر از `min_days_between` است |
|
|
|
|
جای اجرا عمداً لحظهٔ رزرو موقت است نه ثبت نهایی: شنیدن «واجد شرایط نیستید» بعد از ده
|
|
دقیقه نگهداشتن صندلی، هم وقت بیمار را تلف میکند هم صندلی را.
|
|
|
|
جزئیات: [policy.md](policy.md)
|
|
|
|
---
|
|
|
|
## اعتبار پکیج
|
|
|
|
`confirm` یک جلسه از پکیج معتبر بیمار کسر میکند (ردیف `consume`) و لغو نوبت آن را
|
|
برمیگرداند (ردیف `refund`) — ردیف مصرف حذف نمیشود. کلید یکتای دفتر تضمین میکند
|
|
اجرای دوبارهٔ `confirm` جلسهٔ دوم نخورد.
|
|
|
|
جزئیات: [package.md](package.md)
|