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>
151 lines
6.6 KiB
Markdown
151 lines
6.6 KiB
Markdown
# Appointment Plan API — بخشهای نوبت و سازندهٔ برنامه
|
||
|
||
> **Base:** `/api/v1` · **Auth:** JWT
|
||
> وابسته به [resource.md](resource.md) (منبع و مهارت) و [clinic-services.md](clinic-services.md) (مدت آیتمها).
|
||
|
||
---
|
||
|
||
## چرا نوبت یک تکه نیست
|
||
|
||
بند ۷ مستند. یک جلسهٔ لیزر:
|
||
|
||
| بخش | مدت | اتاق | اپراتور | دستگاه |
|
||
|---|---|---|---|---|
|
||
| مالیدن کرم بیحسی | ۵ | اشغال | اشغال | آزاد |
|
||
| انتظار اثر کرم | ۳۰ | اشغال | **آزاد** | آزاد |
|
||
| خود لیزر | ۲۰ | اشغال | اشغال | اشغال |
|
||
| مراقبت بعد | ۵ | اشغال | اشغال | آزاد |
|
||
|
||
با مدل تکبازهای، اپراتور ۶۰ دقیقه قفل میشود در حالی که ۳۰ دقیقه کار میکند —
|
||
**نصف ظرفیت هدر میرود**.
|
||
|
||
⚠️ این بخش هیچ زمان مطلقی و هیچ منبع مشخصی تعیین نمیکند. فقط **شکل** نوبت را میسازد؛
|
||
پیدا کردن وقت و منبع آزاد کارِ تسک بعدی است.
|
||
|
||
---
|
||
|
||
## `GET/PUT /api/v1/service-item/{uuid}/segments`
|
||
|
||
`PUT` جایگزینی کامل است. هر بخش:
|
||
|
||
| فیلد | نوع | توضیح |
|
||
|---|---|---|
|
||
| `sequence` | int | ترتیب اجرا |
|
||
| `name` | string | ✅ الزامی |
|
||
| `duration_source` | `fixed` \| `items` | پیشفرض `fixed` |
|
||
| `duration_minutes` | int | برای `fixed` باید مثبت باشد |
|
||
| `patient_present` | bool | پیشفرض `true`؛ «انتظار در خانه» خلافش است |
|
||
| `mergeable` | bool | با چند آیتم **یک بار** میآید |
|
||
| `requirements` | array | نیازمندی منبع |
|
||
|
||
**`duration_source: "items"`** یعنی مدت این بخش از آیتمهای انتخابشده میآید و با
|
||
فرمول تسک ۰۴ حساب میشود. «خود لیزر» با دو ناحیه طولانیتر میشود، ولی «انتظار اثر
|
||
کرم» نه — به همین دلیل دو منبع مدت لازم است و یک عدد ثابت کافی نیست.
|
||
|
||
هر نیازمندی:
|
||
|
||
| فیلد | توضیح |
|
||
|---|---|
|
||
| `type_uuid` | ✅ نوع منبع (اتاق، اپراتور، دستگاه) |
|
||
| `skill_uuid` | مهارت لازم |
|
||
| `count` | پیشفرض ۱ |
|
||
| `occupancy` | `exclusive` (قفل کامل) یا `shared` (ظرفیت میشمارد) |
|
||
| `constraints` | فعلاً فقط `same_gender_as_patient` |
|
||
|
||
**۴۲۲:** نام خالی · بخش `fixed` با مدت صفر · مجموع بیش از ۴۸۰ دقیقه · `occupancy` یا
|
||
`constraint` ناشناخته.
|
||
|
||
---
|
||
|
||
## `POST /api/v1/appointment-plan/preview`
|
||
|
||
```json
|
||
{
|
||
"service_uuid": "…لیزر",
|
||
"branch_uuid": "…شعبه",
|
||
"item_uuids": ["…صورت", "…بیکینی"],
|
||
"patient_gender": "female"
|
||
}
|
||
```
|
||
|
||
**۲۰۰:**
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"total_minutes": 60,
|
||
"segments": [
|
||
{ "sequence": 1, "name": "بیحسی موضعی", "offset_minutes": 0, "duration_minutes": 5,
|
||
"patient_present": true, "mergeable": false,
|
||
"requirements": [
|
||
{ "role": "room", "role_name": "اتاق", "skill_name": null, "count": 1,
|
||
"occupancy": "exclusive", "constraints": [], "candidates": 3,
|
||
"occupancy_offset": { "setup_minutes": 0, "cleanup_minutes": 0 } }
|
||
] }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
`candidates` تعداد منابع واجد شرایط است. `occupancy_offset` در نمای کاربر نمایش داده
|
||
**نمیشود**؛ برای موتور جستجوی وقت است و محافظهکارانه از بیشترین مقدارِ کاندیدها
|
||
گرفته میشود — کم گرفتنش یعنی نوبت بعدی روی زمان تمیزکاری بیفتد.
|
||
|
||
### خطاها
|
||
|
||
| کد | کِی |
|
||
|---|---|
|
||
| `ERR_NO_ELIGIBLE_RESOURCE` (۴۲۲) | هیچ منبعی شرایط یک بخش را ندارد |
|
||
| `ERR_VALIDATION_002` (۴۲۲) | قید جنسیت هست ولی جنسیت بیمار نامشخص است |
|
||
| `ERR_VALIDATION_001` (۴۲۲) | مدت بخش تعیین نشده · مجموع بیش از سقف |
|
||
| ۴۰۴ | سرویس یا شعبهٔ محیط دیگر |
|
||
|
||
پیام `ERR_NO_ELIGIBLE_RESOURCE` انسانی است و نقش، مهارت و شعبه را میگوید:
|
||
«هیچ اپراتور خانمی با مهارت «لیزر آلکساندرایت» در شعبهٔ «مرکزی» موجود نیست» (بند ۱۰).
|
||
|
||
---
|
||
|
||
## سه قرارداد
|
||
|
||
**۱. سرویس بدون الگوی بخش، همان رفتار امروز را میگیرد.** یک بخش پیوسته به اندازهٔ کل
|
||
مدت که منبعِ `type=doctor` را میگیرد. بدون این، هر سرویس موجود بیبرنامه میشد.
|
||
|
||
**۲. بخش بدون هیچ نیازمندی معتبر است.** «انتظار در خانه» زمان میگیرد ولی هیچ منبعی
|
||
اشغال نمیکند.
|
||
|
||
**۳. قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته نمیشود.** ۴۲۲ میدهد، چون رد
|
||
کردن بیصدا یعنی بیمار به منبعی میرسد که قرار نبود.
|
||
|
||
`mergeable` همنامها یک بار میآیند: «آمادهسازی» با دو ناحیه یک بار انجام میشود.
|
||
تکرار شدنِ کارِ اصلی با `duration_source: "items"` بیان میشود، نه با تکرار بخش.
|
||
|
||
---
|
||
|
||
## طبقهبندی محیط
|
||
|
||
| جدول | وضعیت |
|
||
|---|---|
|
||
| `segment_templates` | جفت محیط (از بخشِ سرویس مشتق میشود) |
|
||
| `segment_requirements` | `AGGREGATE_CHILDREN` — ریشه `SegmentTemplate` |
|
||
|
||
## تستها
|
||
|
||
```bash
|
||
ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۱ تست
|
||
```
|
||
|
||
---
|
||
|
||
## اثر موتور قوانین
|
||
|
||
- دستهٔ `timing`: `min_duration_minutes` (بیشترین برنده) و `add_duration_minutes` (جمع)
|
||
روی **مجموع** نوبت اعمال میشوند؛ رشدِ لازم به **آخرین** بخش میچسبد تا آفست بخشهای
|
||
قبلی جابهجا نشود.
|
||
- دستهٔ `resource`: نقشی که `require_resource` میخواهد، اگر هیچ بخشی نداشته باشد، به
|
||
**اولین بخشی که بیمار حاضر است** اضافه میشود. نقش ناشناخته یا بیمنبع `422` میدهد، نه
|
||
بیاثر ماندن.
|
||
- هر دو روی سرویسِ **بیالگو** هم اجرا میشوند.
|
||
|
||
جزئیات: [policy.md](policy.md)
|