"Six laser sessions" is the common case in an aesthetics clinic: the patient pays once and books the sessions later. Credit is a ledger, not a counter. No table has a remaining/used_count column and a schema test enforces that — the balance is always SUM(delta) over append-only rows, so every number a patient sees has a full history behind it. Corrections are new rows, never edits. - purchase / consume / refund / adjustment / expiry, each with a reason, an author and the appointment it belongs to - consume happens in confirm(), never in quote(): if the preview consumed, a page refresh would cost the patient a session - cancelling adds a refund row; the consume row stays - FIFO across a patient's packages — the oldest is closest to expiring - an empty package is not an error, it just does not apply and the patient pays - adjust/expire need a doctor or clinic role, and adjust always needs a reason - app:package:expire writes the closing row so "where did my 3 sessions go?" always has an answer Consume takes a pessimistic lock on the one package row. That is the opposite of task 07's slot buckets, and docs/api/package.md carries the table explaining why, so nobody unifies them later. Idempotency checks for an existing consume row before inserting rather than catching the unique violation: in Doctrine that exception closes the EntityManager and burns the rest of the request. The unique key stays as the last line of defence. Admin: PackagesPage, a packages tab on the patient record, and a ledger page whose running-balance column shows where the final number came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
8.5 KiB
Markdown
187 lines
8.5 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`
|
|
|
|
جابهجایی: **اول** رزرو جدید، بعد آزادسازی قدیم. ترتیب عمدی است — اگر رزرو جدید شکست
|
|
بخورد، نوبت قدیمی دستنخورده میماند و بیمار بینوبت نمیشود.
|
|
|
|
---
|
|
|
|
## اشغال: یک ردیف 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)
|