Rules become data instead of code: a clinic can say "laser under 18 requires parental consent" without a deploy. Engine - Policy / PolicyVersionLog entities, closed field/operator/effect lists per category (PolicySchema), condition validation at write time - PolicyResolver: priority -> specificity -> age, combining effects by veto / max / sum / union - A missing fact fails its clause instead of silently passing it - Policies are drafts until activated, and are versioned rather than edited Wiring - selection -> ServiceSelectionValidator - eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time - resource + timing -> AppointmentPlanBuilder, including template-less services - pricing -> PricingEngine, alongside (not replacing) the manual discount The condition column is named condition_json: `condition` is a MariaDB keyword and broke every INSERT. Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a clinic with no policies sees byte-identical output to task 08. Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
177 lines
8.1 KiB
Markdown
177 lines
8.1 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)
|