feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# Reports — بهرهوری منابع و دقت برنامه
|
||||
|
||||
اندپوینتهای `src/Report/*`. بند ۱۷ مستند، ریسک سوم: «کلینیک بخشهای نوبت را اشتباه
|
||||
تعریف کند → ظرفیت غلط حساب میشود». این دو گزارش تنها بازخوردیاند که آن اشتباه را
|
||||
نشان میدهند.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/reports/resource-utilization`
|
||||
|
||||
| Query | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `branch_uuid` | string | ✅ | |
|
||||
| `from` / `to` | int | — | Unix؛ پیشفرض هفتهٔ گذشته، حداکثر ۹۰ روز |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"from": 1784880000,
|
||||
"to": 1785484800,
|
||||
"rows": [
|
||||
{
|
||||
"resource_uuid": "…",
|
||||
"resource_name": "اپراتور مریم",
|
||||
"role": "operator",
|
||||
"available_minutes": 2400,
|
||||
"occupied_minutes": 1800,
|
||||
"active_minutes": 1200,
|
||||
"utilization": 0.75,
|
||||
"active_ratio": 0.5,
|
||||
"wasted_capacity": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
سه عدد، سه معنا:
|
||||
|
||||
| عدد | یعنی |
|
||||
|---|---|
|
||||
| `available_minutes` | منبع طبق تقویمش چقدر در دسترس بوده |
|
||||
| `occupied_minutes` | چقدر **گرفته** شده — شامل آمادهسازی، تمیزکاری و بخشهای انتظار |
|
||||
| `active_minutes` | چقدر واقعاً کار شده — فقط بخشهایی که بیمار حاضر بوده |
|
||||
|
||||
فاصلهٔ `occupied` و `active` همان چیزی است که تعریف غلط بخشها را لو میدهد. `active_ratio`
|
||||
زیر ۰٫۳ با `wasted_capacity: true` میآید: منبعی که هشت ساعت اشغال بوده ولی دو ساعت کار
|
||||
کرده یا بخشهای `passive` زیادی گرفته یا انتظارها اشتباه به او نسبت داده شده.
|
||||
|
||||
⚠️ منبعی بدون تقویم `available_minutes: 0` و **`utilization: null`** میدهد، نه صفر:
|
||||
تقسیم بر صفر معنای متفاوتی دارد — بهرهوریاش تعریفنشده است، نه بد.
|
||||
|
||||
ردیفهای `released` (لغوشده) در محاسبه نمیآیند، وگرنه هر لغو بهرهوری را بالا میبرد.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/reports/plan-accuracy`
|
||||
|
||||
| Query | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `from` / `to` | int | — | پیشفرض هفتهٔ گذشته، حداکثر ۹۰ روز |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"from": 1784880000,
|
||||
"to": 1785484800,
|
||||
"rows": [
|
||||
{
|
||||
"service_uuid": "…",
|
||||
"service_name": "لیزر فولبادی",
|
||||
"sample_size": 4,
|
||||
"planned_minutes": 60,
|
||||
"actual_minutes": 90,
|
||||
"deviation_percent": 50,
|
||||
"severity": "high"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| قدر مطلق انحراف | شدت |
|
||||
|---|---|
|
||||
| ≥ ۳۰٪ | `high` |
|
||||
| ≥ ۱۵٪ | `medium` |
|
||||
| ≥ ۵٪ | `low` |
|
||||
| کمتر | `none` |
|
||||
|
||||
شدت از **قدر مطلق** میآید: سرویسی که نصف زمان پیشبینیشده طول میکشد هم غلط تعریف
|
||||
شده — ظرفیتی که میشد فروخت، خالی مانده.
|
||||
|
||||
فقط نوبتهای `completed` شمرده میشوند (لغوشده چیزی دربارهٔ مدت واقعی نمیگوید) و
|
||||
سرویس با کمتر از **سه** نمونه اصلاً نمیآید — میانگین دو نوبت، میانگین نیست.
|
||||
|
||||
مبنای «واقعی» فاصلهٔ ثبتشدهٔ اسلات است، نه ساعت ورود و خروج بیمار؛ آن دومی جایی ثبت
|
||||
نمیشود و حدس زدنش بدتر از نداشتنش است.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/domain-events`
|
||||
|
||||
**Permission:** `ROLE_ADMIN` (بقیه `403`)
|
||||
|
||||
| Query | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | string | فیلتر نام رویداد |
|
||||
| `limit` | int | پیشفرض ۱۰۰، سقف ۵۰۰ |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "…",
|
||||
"name": "AppointmentBooked",
|
||||
"payload": { "appointment_uuid": "…", "hold_uuid": "…" },
|
||||
"occurred_at": 1785484800,
|
||||
"published_at": 1785484802,
|
||||
"attempts": 0,
|
||||
"last_error": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`published_at: null` یعنی هنوز در صندوق خروجی است. جزئیات:
|
||||
[../architecture/domain-events.md](../architecture/domain-events.md)
|
||||
|
||||
---
|
||||
|
||||
## خطاها
|
||||
|
||||
| Code | HTTP | Description |
|
||||
|---|---|---|
|
||||
| `ERR_VALIDATION_001` | 422 | بازهٔ وارونه یا بزرگتر از ۹۰ روز |
|
||||
| `ERR_VALIDATION_002` | 422 | `branch_uuid` غایب |
|
||||
|
||||
## تستها
|
||||
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Report # ۱۶ تست
|
||||
```
|
||||
Reference in New Issue
Block a user