Files
clinicpro/docs/api/resource.md
T
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
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>
2026-07-31 21:27:55 +03:30

414 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Resource API — منابع، نوع منبع، مهارت و استخر
> **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینت‌ها
> **مجوز:** `appointment_settings` (`view` خواندن، `update` نوشتن) — همان مجوز تنظیمات
> نوبت‌دهی؛ مجوز تازه‌ای ساخته نشده.
---
## منبع چیست
قانون طلایی اول مستند: **«تقویم مال منبع است، نه مال پزشک.»** منبع هر چیزی است که
ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.
هر منبع مال یک **شعبه** است و شعبه همان رکورد آدرس محل نوبت‌دهی است
(`doctor_addresses` — رجوع به [branch.md](branch.md)). پس همه‌جا `address_uuid` است،
نه `branch_id`.
### پل، نه ادغام
`Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند
(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاسِ منبع
یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش هر منبع **حداکثر یک** پل دارد:
| `subject_kind` | یعنی |
|---|---|
| `doctor` / `staff` / `room` | منبع همان موجودیت است |
| `null` | دستگاه یا تجهیزات — منبعی که پشتش موجودیت دیگری نیست |
قید «حداکثر یکی» در خودِ entity اجبار می‌شود، نه با `CHECK` دیتابیس: MariaDB قید
چندستونی را قابل اتکا اجرا نمی‌کند.
**یکتایی `(doctor_id, address_id)` است، نه `(doctor_id)`.** یک `WeeklySchedule` per
جفت (پزشک، کلینیک) است ولی هر شیفتِ درونش `location_id` خودش را دارد، پس یک پزشک از
قبل در چند آدرسِ یک محیط کار می‌کند. کلید زدن فقط روی پزشک، این واقعیت را غیرقابل‌بیان
می‌کرد — و تقویمِ تسک ۰۳ دقیقاً per مکان است.
---
## سه قرارداد که باید بدانید
**۱. `capacity` یعنی هم‌زمانی، نه تعداد ردیف.**
اتاق تزریق سه‌تخته **یک** منبع با ظرفیت ۳ است، نه سه منبع. با سه ردیف، موتور جستجو
باید سه تقویم را ادغام کند و «کدام تخت» بشود تصمیمی که هیچ‌کس نمی‌خواهد بگیرد. با
ظرفیت ۳، شرط اشغال یک شمارش ساده در برابر یک سقف است.
منبعی که یک **شخص** است (پزشک/پرسنل) ظرفیت بیش از ۱ نمی‌پذیرد.
**۲. `setup_minutes` / `cleanup_minutes` جزو نوبت بیمار نیستند.**
بیمار ساعت ۱۰:۰۰ می‌آید و ۱۰:۳۰ می‌رود؛ ولی یونیت از ۹:۵۵ تا ۱۰:۴۰ در دسترس نیست.
با `WeeklySchedule.meta.buffer_minutes` قاطی نشود: آن فاصلهٔ سراسری بین دو نوبتِ
**پزشک** است، این per منبع. هر دو کنار هم زندگی می‌کنند و آن یکی دست‌نخورده است.
**۳. مهارت یک جدول است، نه یک قانون.**
«کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است. با ۵۰ اپراتور و ۲۰۰
سرویس، سپردنش به موتور قوانین یعنی ۱۰٬۰۰۰ قانون.
با `ClinicStaff.job_title` قاطی نشود: آن متن آزاد و فقط برای نمایش است و هیچ‌جا برای
تصمیم‌گیری parse نمی‌شود.
⚠️ همهٔ اندپوینت‌ها برای دادهٔ محیط دیگر **۴۰۴** می‌دهند، نه ۴۰۳ — وجود دادهٔ محیط
بیگانه لو نمی‌رود. مالکیت صریح سنجیده می‌شود و به `TenantFilter` تکیه نمی‌شود، چون
جداسازی سختِ فیلتر فقط روی محیطِ *انتخاب‌شده* اعمال می‌شود
([tenancy.md](../architecture/tenancy.md)).
---
## نوع منبع
### `GET /api/v1/resource-types`
خروجی واقعی (سه نوع سیستمی را `app:resource:backfill` ساخته):
```json
{
"success": true,
"data": [
{ "uuid": "c39053f3-a051-4f03-942f-08a2274bf658", "code": "room", "name": "اتاق", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 },
{ "uuid": "c0447da1-30af-430b-8cbd-b464ccd81624", "code": "staff", "name": "پرسنل", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 },
{ "uuid": "dcabd1c3-d554-4613-a33d-7c69b7ae7efe", "code": "doctor", "name": "پزشک", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 1 }
]
}
```
`resources_count` با **یک** کوئری گروهی پر می‌شود، نه یکی per نوع.
### `POST /api/v1/resource-types`
| فیلد | نوع | الزامی | قاعده |
|---|---|---|---|
| `code` | string | ✅ | `[a-z0-9_]{1,40}` · یکتا **per محیط** (همان کد در محیط دیگر مجاز است) |
| `name` | string | ✅ | نام نمایشی فارسی |
**۲۰۱** (خروجی واقعی):
```json
{
"success": true,
"data": {
"uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
"code": "laser",
"name": "دستگاه لیزر",
"is_system": false,
"active": true,
"created_at": 1785420676,
"updated_at": 1785420676,
"resources_count": 0
}
}
```
**۴۲۲:** کد تکراری در همان محیط (field `code`) · کد نامعتبر (field `code`) · نام خالی.
### `PATCH /api/v1/resource-type/{uuid}`
فقط `name` و `active`. **`code` تغییر نمی‌کند** حتی روی نوع غیرسیستمی: منابع موجود و
پل خودکار با همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند. فرستادنش خطا
نمی‌دهد، نادیده گرفته می‌شود.
### `DELETE /api/v1/resource-type/{uuid}`
**۴۲۲** وقتی `is_system` است («نوع منبع سیستمی حذف نمی‌شود») یا منبعی از آن نوع وجود
دارد («این نوع روی N منبع استفاده شده است»).
---
## مهارت
### `GET /api/v1/skills` · `POST /api/v1/skills`
بدنهٔ ساخت فقط `name`. **۲۰۱** (خروجی واقعی):
```json
{
"success": true,
"data": {
"uuid": "48e60355-6788-4202-a775-a07986658662",
"name": "لیزر آلکساندرایت",
"active": true,
"created_at": 1785420692,
"updated_at": 1785420692,
"resources_count": 0
}
}
```
### `PATCH /api/v1/skill/{uuid}` — `name` و `active`
### `DELETE /api/v1/skill/{uuid}`
مهارتی که روی منبعی نشسته حذف نمی‌شود؛ وگرنه `ON DELETE RESTRICT` خطای خام دیتابیس
می‌داد. **۴۲۲** (خروجی واقعی):
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"این مهارت به 1 منبع داده شده است؛ اول از آن‌ها برداشته شود"}]}
```
> رقم لاتین در پیام عمدی است — قرارداد پیام‌های درون‌ریزیِ بک‌اند همین است و
> قالب‌بندی فارسی کارِ نمایش در کلاینت است.
---
## منابع
### `GET /api/v1/resources`
| Query | توضیح |
|---|---|
| `address_uuid` | فقط منابع این شعبه |
| `type_uuid` | فقط این نوع |
| `skill_uuid` | فقط منابعی که این مهارت را دارند |
| `active` | `1` / `0` |
| `clinic_uuid` | انتخاب صریح محیط |
`skill_uuid` متعلق به محیط دیگر **۴۰۴** می‌دهد، نه «هیچ نتیجه» — سکوت اینجا یعنی
دیباگِ کور.
### `POST /api/v1/resource`
| فیلد | نوع | الزامی | قاعده |
|---|---|---|---|
| `address_uuid` | string | ✅ | شعبه؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه |
| `type_uuid` | string | ✅ | |
| `name` | string | ✅ | حداکثر ۱۵۰ نویسه |
| `capacity` | int | — | پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ |
| `setup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ |
| `cleanup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ |
| `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر |
| `active` | bool | — | پیش‌فرض `true` |
`attributes` عمداً آزاد است — کلید ناشناخته پذیرفته می‌شود — ولی مقدارش باید ساده
باشد. دلیل: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را با مقایسهٔ ساده روی
همین مقادیر می‌سنجند؛ آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، همان چیزی که بند ۸ مستند
ممنوع کرده. کلیدهای قراردادی: `gender`, `device_model`, `floor`, `brand`.
**۲۰۱** (خروجی واقعی):
```json
{
"success": true,
"data": {
"uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2",
"name": "لیزر آلکساندرایت ۱",
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"address_name": "درمانگاه شبانه روزی صدرا ",
"type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
"type_code": "laser",
"type_name": "دستگاه لیزر",
"capacity": 1,
"setup_minutes": 5,
"cleanup_minutes": 10,
"attributes": { "device_model": "Candela GentleLase", "floor": "2" },
"subject_kind": null,
"subject_uuid": null,
"skills": [],
"active": true,
"created_at": 1785420692,
"updated_at": 1785420692
}
}
```
**۴۲۲ — ظرفیت روی منبعِ شخص** (خروجی واقعی):
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منبعی که یک شخص است نمی‌تواند ظرفیت بیش از ۱ داشته باشد","field":"capacity"}]}
```
**۴۲۲ — ویژگی غیر اسکالر** (خروجی واقعی):
```json
{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"مقدار ویژگی «nested» باید یک مقدار ساده باشد","field":"attributes"}]}
```
سایر ۴۲۲ها: `capacity < 1` · نام خالی · کلید ویژگی غیر snake_case ·
`setup/cleanup` بیرون از ۰..۴۸۰.
### `GET/PATCH/DELETE /api/v1/resource/{uuid}`
`PATCH` همان فیلدهاست؛ `address_uuid` پذیرفته **نمی‌شود** (جفت محیط از آدرس مشتق شده
و write-once است) و `type_uuid` قابل تغییر است.
### `PUT /api/v1/resource/{uuid}/skills`
جایگزینی **کامل**: مهارتی که در بدنه نیست، برداشته می‌شود. `{"skills":[]}` همه را
پاک می‌کند.
```json
{ "skills": [ { "skill_uuid": "48e60355-…", "level": 4 } ] }
```
`level` بین ۱ و ۵ (پیش‌فرض ۱). از روز اول هست چون استراتژی «حفظ متخصص‌ها» در تسک ۰۶
رویش ساخته می‌شود و افزودنش بعداً یعنی backfill با حدس.
پاسخ ۲۰۰ کلِ منبع است؛ بخش `skills` آن (خروجی واقعی):
```json
"skills": [
{ "skill_uuid": "48e60355-6788-4202-a775-a07986658662", "skill_name": "لیزر آلکساندرایت", "level": 4 }
]
```
**۴۲۲:** `level` بیرون از ۱..۵ (field `level`) · مهارت تکراری در یک بدنه ·
`skill_uuid` غایب. **۴۰۴:** مهارت محیط دیگر.
> **اتمی است.** اعتبارسنجی کل فهرست پیش از هر حذفی انجام می‌شود، پس یک ردیف نامعتبر
> در انتهای فهرست، مهارت‌های درستِ قبلی را پاک نمی‌کند و بعد ۴۲۲ برگرداند.
---
## استخر منابع
گروهی از منابع که **جایگزین کامل** یکدیگرند: «لیزرهای آلکساندرایت»، «اتاق‌های معاینه».
### `GET/POST /api/v1/resource-pools`
بدنهٔ ساخت: `address_uuid` · `type_uuid` · `name`.
### `GET/PATCH/DELETE /api/v1/resource-pool/{uuid}`
`PATCH` فقط `name` و `active`. `DELETE` اعضا را با CASCADE می‌برد ولی **خودِ منابع
دست‌نخورده می‌مانند**.
### `PUT /api/v1/resource-pool/{uuid}/members`
```json
{ "members": [ { "resource_uuid": "38bdd1e9-…", "priority": 0 } ] }
```
هر عضو باید **همان شعبه و همان نوعِ** استخر را داشته باشد. تسک ۰۶ فرض می‌کند هر عضو
جایگزین کامل دیگری است: عضوی از شعبهٔ دیگر یعنی بیمار در ساختمان اشتباه می‌ایستد، و
عضوی از نوع دیگر یعنی صندلی به‌جای دستگاه لیزر پیشنهاد می‌شود.
`priority` ترتیب ترجیح در استراتژی انتخاب است؛ کوچک‌تر زودتر.
**۲۰۰** (خروجی واقعی):
```json
{
"success": true,
"data": {
"uuid": "9d50cf4a-bef6-423c-85e9-530bf3609964",
"name": "لیزرهای آلکساندرایت",
"address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6",
"address_name": "درمانگاه شبانه روزی صدرا ",
"type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e",
"type_code": "laser",
"type_name": "دستگاه لیزر",
"members": [
{ "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", "resource_name": "لیزر آلکساندرایت ۱", "priority": 0, "active": true }
],
"active": true,
"created_at": 1785420708,
"updated_at": 1785420708
}
}
```
**۴۲۲:** «همهٔ اعضای استخر باید در یک شعبه باشند» · «… از یک نوع منبع باشند» · عضو
تکراری. مثل مهارت‌ها، اعتبارسنجی پیش از حذف است.
استخر **بدون عضو** معتبر است (در حال ساخت)، ولی تسک ۰۶ آن را «هیچ منبعی» می‌بیند.
---
## `app:resource:backfill`
هر موجودیت قابل‌اشغالِ موجود را به یک منبع پل می‌زند.
```bash
ddev exec php bin/console app:resource:backfill # dry-run
ddev exec php bin/console app:resource:backfill --force
ddev exec php bin/console app:resource:backfill --force --pair=clinic:12
```
| مورد | رفتار |
|---|---|
| اتاق | آدرسش را خودش دارد → منبع با **همان `capacity`** و همان `active` |
| پزشک | یک منبع per آدرسی که در برنامهٔ هفتگی **شیفت فعال** دارد (`location_id`) |
| پرسنل | فقط اگر محیط دقیقاً **یک** شعبه دارد؛ وگرنه رد و **گزارش** می‌شود |
پرسنل تنها موردی است که قابل استنتاج نیست: هیچ ستونی نمی‌گوید در کدام شعبه کار می‌کند.
حدس زدنِ «اولین شعبه» او را در ساختمان اشتباه می‌نشاند، پس گزارش می‌شود تا کاربر خودش
تعیین کند.
`--pair` هم برای عملیات است (اجرای دوباره برای یک کلینیک) و هم دامنه را محدود می‌کند.
دستور **per محیط flush می‌کند**، پس یک ردیف خراب کل اجرای چندهزارمحیطی را با
`EntityManagerClosed` از پا نمی‌اندازد.
idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ متناظر است، نه یک پرچم جداگانه.
---
## طبقه‌بندی محیط
| جدول | وضعیت |
|---|---|
| `resource_types` · `clinic_resources` · `skills` · `resource_pools` | جفت `(entity_type, entity_id)` |
| `resource_skills` · `resource_pool_members` | `AGGREGATE_CHILDREN` — ریشه‌هاشان خودشان جفت دارند |
---
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Resource # ۵۲ تست / ۱۲۹ assertion
ddev exec php vendor/bin/phpstan analyse src/Resource
npx vitest run assets/admin/pages/ResourcesPage.test.tsx
```
---
## مسدودسازی موردی
«این بعدازظهر دستگاه سرویس دارد» — یک بازهٔ مشخص که منبع در دسترس نیست.
| | مسدودسازی موردی | استثنای تقویم |
|---|---|---|
| چیست | یک بازهٔ مشخص | تغییر الگوی تکرارشوندهٔ کاری |
| کجا | `resource_occupancy` | `resource_exceptions` |
| چقدر می‌ماند | تا وقتی حذفش کنی | بخشی از تعریف تقویم |
ادغامشان یعنی یا تعطیلی یک بعدازظهر برای همیشه در تقویم بماند، یا تغییر ساعت کاری با
یک کلیک ناپدید شود.
### GET `/api/v1/resource/{uuid}/blocks`
| Query | Type | Description |
|---|---|---|
| `from` / `to` | int | پیش‌فرض: از حالا تا ۳۰ روز بعد |
فقط مسدودسازی‌های **دستی** برمی‌گردند؛ اشغالِ نوبت‌ها اینجا نمی‌آید.
### POST `/api/v1/resource/{uuid}/blocks`
```json
{ "starts_at": 1785600000, "ends_at": 1785614400, "reason": "سرویس دوره‌ای دستگاه" }
```
| Code | HTTP | Description |
|---|---|---|
| `ERR_VALIDATION_002` | 422 | بازه غایب |
| `ERR_VALIDATION_001` | 422 | پایان قبل از شروع |
| `ERR_SLOT_TAKEN` | 409 | در این بازه نوبت یا رزرو موقت هست |
مسدودسازی روی بازه‌ای که نوبت دارد **ظرفیت را پس نمی‌گیرد**: نوبت سرجایش می‌ماند و
کاربر باید اول تکلیفش را روشن کند.
### DELETE `/api/v1/resource-block/{uuid}`
اشغالی که به نوبت یا رزرو موقت وصل است از این مسیر حذف **نمی‌شود** (`422`) — وگرنه
نوبت بیمار بی‌صدا منبعش را از دست می‌داد.
هر دو عمل رویداد دامنه ثبت می‌کنند: `ResourceBlocked` و `ResourceReleased`. ظرفیتی که
برمی‌گردد باید همان‌قدر شنیده شود که ظرفیتی که می‌رود؛ مصرف‌کننده‌ای که فقط اولی را
بشنود، منبع را برای همیشه اشغال می‌بیند.