diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 5394d2ce..06945665 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; +import ResourceUtilizationPage from './pages/ResourceUtilizationPage'; +import PlanAccuracyPage from './pages/PlanAccuracyPage'; import CancellationPolicyPage from './pages/CancellationPolicyPage'; import WaitlistPage from './pages/WaitlistPage'; import CourseProtocolsPage from './pages/CourseProtocolsPage'; @@ -305,6 +307,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 69eb90a9..eac6db6c 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon, ArrowPathRoundedSquareIcon, NoSymbolIcon, QueueListIcon, ChartBarIcon, } from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; @@ -37,6 +37,8 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'utilization', label: 'بهره‌وری منابع', icon: ChartBarIcon, to: '/admin/reports/resource-utilization', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'plan-accuracy', label: 'دقت برنامه', icon: ChartBarIcon, to: '/admin/reports/plan-accuracy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/hooks/useReports.ts b/assets/admin/hooks/useReports.ts new file mode 100644 index 00000000..356d92d2 --- /dev/null +++ b/assets/admin/hooks/useReports.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { api, type ApiResponse } from '../lib/api'; +import type { AccuracyRow, ReportEnvelope, UtilizationRow } from '../types'; + +/** + * گزارش‌های بهره‌وری و دقت برنامه. + * + * بازه همیشه صریح فرستاده می‌شود تا نمودار با پیش‌فرض سرور جابه‌جا نشود. + */ +export function useResourceUtilization(branchUuid: string | undefined, from: number, to: number) { + const query = useQuery({ + queryKey: ['resource-utilization', branchUuid, from, to], + queryFn: () => + api.get>>( + `/api/v1/reports/resource-utilization?branch_uuid=${branchUuid}&from=${from}&to=${to}`, + ), + enabled: !!branchUuid, + }); + + return { rows: query.data?.data?.rows ?? [], loading: query.isLoading }; +} + +export function usePlanAccuracy(from: number, to: number) { + const query = useQuery({ + queryKey: ['plan-accuracy', from, to], + queryFn: () => + api.get>>( + `/api/v1/reports/plan-accuracy?from=${from}&to=${to}`, + ), + }); + + return { rows: query.data?.data?.rows ?? [], loading: query.isLoading }; +} diff --git a/assets/admin/pages/PlanAccuracyPage.test.tsx b/assets/admin/pages/PlanAccuracyPage.test.tsx new file mode 100644 index 00000000..1cd57194 --- /dev/null +++ b/assets/admin/pages/PlanAccuracyPage.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { api } from '../lib/api'; +import PlanAccuracyPage from './PlanAccuracyPage'; + +const get = api.get as ReturnType; + +const rows = [ + { + service_uuid: 's1', + service_name: 'لیزر فول‌بادی', + sample_size: 4, + planned_minutes: 60, + actual_minutes: 90, + deviation_percent: 50, + severity: 'high' as const, + }, + { + service_uuid: 's2', + service_name: 'مشاوره', + sample_size: 5, + planned_minutes: 30, + actual_minutes: 29, + deviation_percent: -3, + severity: 'none' as const, + }, +]; + +describe('PlanAccuracyPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + get.mockResolvedValue({ success: true, data: { from: 1, to: 2, rows } }); + }); + + it('shows planned against actual with a signed deviation', async () => { + renderWithProviders(, { route: '/admin/reports/plan-accuracy' }); + + await waitFor(() => expect(screen.getByText('لیزر فول‌بادی')).toBeInTheDocument()); + + expect(screen.getByText('+50٪')).toBeInTheDocument(); + expect(screen.getByText('-3٪')).toBeInTheDocument(); + expect(screen.getByText('زیاد')).toBeInTheDocument(); + expect(screen.getByText('دقیق')).toBeInTheDocument(); + }); + + /** نمونهٔ کوچک از گزارش حذف می‌شود؛ صفحه باید همان قاعده را بگوید. */ + it('explains that small samples are excluded', async () => { + renderWithProviders(, { route: '/admin/reports/plan-accuracy' }); + + await waitFor(() => + expect(screen.getByText(/کمتر از سه نوبت انجام‌شده در گزارش نمی‌آیند/)).toBeInTheDocument(), + ); + }); +}); diff --git a/assets/admin/pages/PlanAccuracyPage.tsx b/assets/admin/pages/PlanAccuracyPage.tsx new file mode 100644 index 00000000..36cae26b --- /dev/null +++ b/assets/admin/pages/PlanAccuracyPage.tsx @@ -0,0 +1,123 @@ +import React, { useMemo, useState } from 'react'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { Link } from 'react-router-dom'; +import { usePlanAccuracy } from '../hooks/useReports'; +import type { AccuracyRow } from '../types'; + +const RANGES = [ + { value: '30', label: 'ماه گذشته' }, + { value: '90', label: 'سه ماه گذشته' }, +]; + +const SEVERITY: Record = { + none: { label: 'دقیق', className: 'badge green' }, + low: { label: 'کم', className: 'badge' }, + medium: { label: 'متوسط', className: 'badge amber' }, + high: { label: 'زیاد', className: 'badge red' }, +}; + +/** + * مدت پیش‌بینی‌شده در برابر مدت واقعی. + * + * سرویسی که یک ساعت پیش‌بینی شده ولی یک‌ساعت‌ونیم طول می‌کشد، هر روز نیم ساعت از ظرفیت + * کلینیک را بی‌صدا می‌خورد — این صفحه تنها جایی است که آن را نشان می‌دهد. + */ +export default function PlanAccuracyPage() { + const [days, setDays] = useState('30'); + + const range = useMemo(() => { + const to = Math.floor(Date.now() / 1000); + return { from: to - Number(days) * 86400, to }; + }, [days]); + + const { rows, loading } = usePlanAccuracy(range.from, range.to); + + const columns: Column[] = [ + { + key: 'service_name', + header: 'خدمت', + render: (r) => ( +
+ {r.service_name} + {r.sample_size} نوبت +
+ ), + }, + { + key: 'planned_minutes', + header: 'پیش‌بینی', + render: (r) => {r.planned_minutes} دقیقه, + }, + { + key: 'actual_minutes', + header: 'واقعی', + render: (r) => {r.actual_minutes} دقیقه, + }, + { + key: 'deviation_percent', + header: 'انحراف', + render: (r) => ( + + {r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪ + + ), + }, + { + key: 'fix', + header: '', + // گزارشی که راه اصلاح ندهد خوانده نمی‌شود. + render: (r) => ( + + ویرایش بخش‌های این خدمت + + ), + }, + { + key: 'severity', + header: 'شدت', + render: (r) => ( + + + {SEVERITY[r.severity].label} + + ), + }, + ]; + + return ( +
+ + +
+
+ + setDays(String(v ?? '30'))} options={RANGES} /> +
+ + خدماتی با کمتر از سه نوبت انجام‌شده در گزارش نمی‌آیند. + +
+ +
+ +
+
+ ); +} diff --git a/assets/admin/pages/ResourceUtilizationPage.tsx b/assets/admin/pages/ResourceUtilizationPage.tsx new file mode 100644 index 00000000..34996ae9 --- /dev/null +++ b/assets/admin/pages/ResourceUtilizationPage.tsx @@ -0,0 +1,139 @@ +import React, { useMemo, useState } from 'react'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { useBranches } from '../hooks/useBranches'; +import { useResourceUtilization } from '../hooks/useReports'; +import type { UtilizationRow } from '../types'; + +const RANGES = [ + { value: '7', label: 'هفتهٔ گذشته' }, + { value: '30', label: 'ماه گذشته' }, + { value: '90', label: 'سه ماه گذشته' }, +]; + +function percent(value: number | null): string { + return value === null ? '—' : `${Math.round(value * 100)}٪`; +} + +/** + * بهره‌وری منابع. + * + * ستون «نسبت کار مفید» مهم‌ترین ستون است: فاصله‌اش با «اشغال» همان چیزی است که تعریف + * غلط بخش‌ها را لو می‌دهد. + */ +export default function ResourceUtilizationPage() { + const { branches } = useBranches(); + const [branchUuid, setBranchUuid] = useState(''); + const [days, setDays] = useState('7'); + + const range = useMemo(() => { + const to = Math.floor(Date.now() / 1000); + return { from: to - Number(days) * 86400, to }; + }, [days]); + + const { rows, loading } = useResourceUtilization(branchUuid || undefined, range.from, range.to); + + const columns: Column[] = [ + { + key: 'resource_name', + header: 'منبع', + render: (r) => ( +
+ {r.resource_name} + {r.role} +
+ ), + }, + { + key: 'available_minutes', + header: 'در دسترس', + render: (r) => {r.available_minutes} دقیقه, + }, + { + key: 'occupied_minutes', + header: 'اشغال', + render: (r) => {r.occupied_minutes} دقیقه, + }, + { + key: 'active_minutes', + header: 'کار مفید', + render: (r) => {r.active_minutes} دقیقه, + }, + { + key: 'utilization', + header: 'بهره‌وری', + render: (r) => ( + + {percent(r.utilization)} + + ), + }, + { + key: 'active_ratio', + header: 'نسبت کار مفید', + // توضیح باید در خودِ صفحه باشد، نه فقط در مستندات: کسی که گزارش را می‌خواند + // مستندات را باز نمی‌کند. + render: (r) => ( + + {percent(r.active_ratio)} + + ), + }, + { + key: 'wasted_capacity', + header: '', + render: (r) => + r.wasted_capacity ? ( + ظرفیت هدررفته + ) : null, + }, + ]; + + return ( +
+ + +
+
+ + setBranchUuid(String(v ?? ''))} + options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))} + placeholder="انتخاب شعبه" + /> +
+ +
+ + setDays(String(v ?? '7'))} options={RANGES} /> +
+
+ +

+ «اشغال» شامل آماده‌سازی، تمیزکاری و بخش‌های انتظار است؛ «کار مفید» فقط زمانی که + بیمار حاضر بوده. فاصلهٔ این دو نشان می‌دهد بخش‌های نوبت درست تعریف شده‌اند یا نه. +

+ +
+ +
+
+ ); +} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index c4c7fadd..362af9fc 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -1373,3 +1373,34 @@ export interface WaitlistEntry { notify_count: number; created_at: number; } + +// ── گزارش‌ها (تسک ۱۴) ──────────────────────────────────────────────────────── + +export interface UtilizationRow { + resource_uuid: string; + resource_name: string; + role: string; + available_minutes: number; + occupied_minutes: number; + active_minutes: number; + /** `null` یعنی تقویمی نیست — تعریف‌نشده، نه صفر */ + utilization: number | null; + active_ratio: number | null; + wasted_capacity: boolean; +} + +export interface AccuracyRow { + service_uuid: string; + service_name: string; + sample_size: number; + planned_minutes: number; + actual_minutes: number; + deviation_percent: number; + severity: 'none' | 'low' | 'medium' | 'high'; +} + +export interface ReportEnvelope { + from: number; + to: number; + rows: T[]; +} diff --git a/docs/api/reports.md b/docs/api/reports.md new file mode 100644 index 00000000..3ac648ca --- /dev/null +++ b/docs/api/reports.md @@ -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 # ۱۶ تست +``` diff --git a/docs/architecture/domain-events.md b/docs/architecture/domain-events.md new file mode 100644 index 00000000..c47513cc --- /dev/null +++ b/docs/architecture/domain-events.md @@ -0,0 +1,106 @@ +# رویدادهای دامنه + +سیستم وقتی چیزی اتفاق می‌افتد یک **رویداد** ثبت می‌کند تا بقیه (پیامک، حسابداری، گزارش) +واکنش نشان بدهند — بدون اینکه دامنهٔ نوبت‌دهی از وجودشان خبر داشته باشد. + +مرجع: بند ۱۶ مستند طراحی. اندپوینت عیب‌یابی: [../api/reports.md](../api/reports.md) + +--- + +## سه قاعدهٔ غیرقابل‌مذاکره + +۱. **payload فقط uuid و اسکالر است.** هیچ entity ای در رویداد نیست؛ مصرف‌کننده خودش + واکشی می‌کند. entity در پیام async یعنی سریال‌سازی، detach شدن، و دادهٔ کهنه. + `DomainEventLog` مقادیر غیراسکالر را **حذف** می‌کند، نه اینکه سریال‌شان کند. +۲. **انتشار بعد از commit.** ردیف رویداد در همان تراکنشی نوشته می‌شود که تغییر را + انجام می‌دهد؛ انتشار جداست. +۳. **هر رویداد محیط دارد.** بدون `entity_type`/`entity_id`، پیامک کلینیک الف به شمارهٔ + کلینیک ب می‌رود. + +--- + +## چرا صندوق خروجی (outbox) + +بدون آن دو حالت شکست ممکن است: + +| حالت | نتیجه | +|---|---| +| انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد | +| commit موفق، انتشار شکست خورد (Redis down) | نوبت هست، هیچ‌کس مطلع نشد | + +با outbox ردیف رویداد **در همان تراکنش** ثبت می‌شود و یک worker بعداً منتشرش می‌کند: + +```bash +ddev exec php bin/console app:events:publish --limit=100 +``` + +حداکثر **تأخیر** داریم، هرگز گم‌شدن. + +`DomainEventPublisher::record()` عمداً flush نمی‌کند — همان چیزی که تضمین می‌کند رویداد +با تراکنشِ برگشته از بین برود. جایی که فراخوان تراکنش باز ندارد، `recordAndFlush()` هست. + +### ردیف مرده + +بعد از پنج تلاش ناموفق، ردیف با `last_error` **باقی می‌ماند** و دیگر برداشته نمی‌شود. +حذف خاموش یعنی رویداد گم‌شدهٔ بی‌رد؛ ادمین باید بتواند ببیند چه چیزی منتشر نشد و چرا. + +--- + +## `AppointmentEvent` یا `DomainEventLog`؟ + +هر دو ماندند و کارشان یکی نیست: + +| | `AppointmentEvent` | `DomainEventLog` | +|---|---|---| +| چیست | تاریخچهٔ تغییر وضعیت **یک نوبت** | اعلان تغییر به بیرونِ دامنه | +| مخاطب | خودِ صفحهٔ نوبت | پیامک، حسابداری، گزارش | +| دامنه | فقط نوبت | همهٔ دامنه‌ها | +| مصرف | خوانده می‌شود | منتشر می‌شود | + +ادغامشان یعنی تاریخچهٔ نوبت به صف پیام تبدیل شود، یا صف پیام پر از جزئیاتی که فقط یک +صفحه لازم دارد. + +--- + +## فهرست رویدادها + +``` +HoldCreated AppointmentBooked +AppointmentCancelled AppointmentRescheduled +PatientNoShow AppointmentCompleted +ResourceBlocked ResourceReleased +CourseStarted CourseSessionCompleted +CourseCompleted PackagePurchased +CreditConsumed CreditRefunded +``` + +فهرست **بسته** است (`DomainEvents::ALL`) و نام ناشناخته استثنا می‌دهد: مصرف‌کننده روی +رشته شرط می‌گذارد، و تایپوی یک حرفی یعنی رویدادی که هیچ‌کس نمی‌شنود و هیچ خطایی هم +نمی‌دهد. + +### وضعیت فعلی انتشار + +| رویداد | کجا ثبت می‌شود | +|---|---| +| `HoldCreated` | `HoldService::hold()` — بعد از گرفتن همهٔ منابع | +| `AppointmentBooked` | `BookingService::confirm()` | +| `AppointmentCancelled` | `BookingService::cancel()` | +| `PatientNoShow` | `NoShowService::record()` | +| `CourseStarted` | `CourseStarter::start()` | +| `CourseSessionCompleted` · `CourseCompleted` | `CourseSessionLinker::complete()` | +| `PackagePurchased` | `PackageSalesService::sell()` | +| `CreditConsumed` · `CreditRefunded` | `CreditLedgerService` | + +`AppointmentRescheduled`، `AppointmentCompleted`، `ResourceBlocked` و `ResourceReleased` +هنوز نقطهٔ ثبت ندارند: مسیرهایشان (جابه‌جایی نوبت، تکمیل دستی، بلوک منبع) از تسک‌های +قبلی‌اند و دست‌زدن به آن‌ها بیرون از دامنهٔ این تسک بود. + +--- + +## مصرف‌کنندهٔ تازه + +`DomainEventHandler` فقط لاگ می‌کند و **نباید بیشتر بکند**؛ درزِ اتصال است. مصرف‌کنندهٔ +تازه کنارش ثبت می‌شود و باید **idempotent** باشد: messenger ممکن است پیام را دوباره +تحویل بدهد، و `DomainEventMessage::$uuid` همان شناسه‌ای است که با آن تکراری را می‌شناسد. + +تکرار مسئلهٔ مصرف‌کننده است، نه رویداد: تضمین «دقیقاً یک بار» در صف توزیع‌شده وجود ندارد. diff --git a/docs/new_feture/taskes/task-14-events-utilization/checklist.md b/docs/new_feture/taskes/task-14-events-utilization/checklist.md index 28058be6..0ec9bb66 100644 --- a/docs/new_feture/taskes/task-14-events-utilization/checklist.md +++ b/docs/new_feture/taskes/task-14-events-utilization/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۱۴ (رویدادهای دامنه و گزارش بهره‌وری) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تمام‌شده با انحراف‌های ثبت‌شده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹ قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) · [red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md) @@ -11,109 +11,111 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `AppointmentEvent` موجود دست‌نخورده | ⏳ | تاریخچهٔ وضعیت ≠ رویداد دامنه | -| ۰.۳ | پیامک‌های موجود (`Sms` domain) نشکستند | ⏳ | | -| ۰.۴ | گزارش با داده حدسی ساخته **نشد** | ⏳ | ⭐ بند ۱.۹ | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۰.۲ | `AppointmentEvent` دست‌نخورده | ✅ | جدول تفاوت در `domain-events.md` | +| ۰.۳ | پیامک‌های موجود نشکستند | ✅ | `Sms` domain دست نخورد؛ تست‌هایش سبز | +| ۰.۴ | گزارش با داده حدسی ساخته نشد | ✅ | ⭐ مبنای «واقعی» فاصلهٔ ثبت‌شدهٔ اسلات است و همین در سند نوشته شد — نه حدسِ ساعت ورود و خروج | ## ۱. بک‌اند — رویدادها | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `DomainEvent` پایه + `DomainEventPublisher` + `DomainEventLog` | ⏳ | | -| ۱.۲ | payload **فقط uuid و اسکالر** — هیچ entity | ⏳ | ⭐ | -| ۱.۳ | هر رویداد `entityType`/`entityId` دارد | ⏳ | وگرنه پیامک محیط اشتباه | -| ۱.۴ | الگوی **outbox**: `record()` داخل تراکنش کاری، فقط persist | ⏳ | ⭐ | -| ۱.۵ | `PublishDomainEventHandler` + `scheduler` هر ۱۰ ثانیه | ⏳ | | -| ۱.۶ | `attempts < 5`؛ ردیف شکست‌خورده **حذف نمی‌شود** | ⏳ | | -| ۱.۷ | همهٔ `dispatch` های تسک‌های ۰۷ تا ۱۳ به `record()` تغییر کردند | ⏳ | ⭐ | -| ۱.۸ | چهارده رویداد بند ۱۶ مستند ثبت شدند | ⏳ | | -| ۱.۹ | idempotency در **مصرف‌کننده**، با `domain_events.uuid` | ⏳ | at-least-once | -| ۱.۱۰ | worker با loop-wrap برای Coolify | ⏳ | کانتینر خارج نشود | -| ۱.۱۱ | `app:events:prune --older-than=180d` | ⏳ | | -| ۱.۱۲ | `GET /domain-events` فقط `ROLE_ADMIN` | ⏳ | | +| ۱.۱ | `DomainEvents` + `DomainEventPublisher` + `DomainEventLog` | ⚠️ | به‌جای کلاس پایهٔ `DomainEvent` و زیرکلاس per رویداد، یک فهرست بستهٔ نام + یک entity. چهارده زیرکلاس خالی فقط برای اینکه نام را در تایپ نگه دارند، همان کاری را می‌کنند که `const` می‌کند | +| ۱.۲ | payload فقط uuid و اسکالر | ✅ | ⭐ مقادیر غیراسکالر **حذف** می‌شوند، نه سریال | +| ۱.۳ | هر رویداد محیط دارد | ✅ | `TenantOwnedTrait` | +| ۱.۴ | outbox — `record()` فقط persist | ✅ | ⭐ تست rollback | +| ۱.۵ | worker انتشار | ⚠️ | `app:events:publish` هست؛ ثبتش در `scheduler` انجام نشد (تصمیم استقرار، نه کد — نیازمند هماهنگی با Coolify) | +| ۱.۶ | سقف تلاش، بدون حذف ردیف شکست‌خورده | ✅ | تست دارد | +| ۱.۷ | همهٔ نقاط به `record()` وصل شدند | ⚠️ | تسک‌های ۰۷ تا ۱۳ اصلاً `dispatch` نداشتند؛ هشت نقطهٔ واقعی وصل شد و چهار رویداد باقی‌مانده نقطهٔ ثبت ندارند (۱.۸) | +| ۱.۸ | چهارده رویداد بند ۱۶ | ⚠️ | ده رویداد ثبت می‌شوند. `AppointmentRescheduled`، `AppointmentCompleted`، `ResourceBlocked`، `ResourceReleased` نام دارند ولی نقطهٔ ثبت ندارند — مسیرهایشان از تسک‌های قبلی‌اند و دست‌زدن به آن‌ها بیرون دامنه بود. فهرست وضعیت در `domain-events.md` | +| ۱.۹ | idempotency در مصرف‌کننده | ✅ | `DomainEventMessage::$uuid` + توضیح صریح در docblock و سند | +| ۱.۱۰ | worker با loop-wrap برای Coolify | ⏳ | با ۱.۵ یک بسته است | +| ۱.۱۱ | `app:events:prune` | ✅ | فقط ردیف **منتشرشده** حذف می‌شود؛ منتشرنشده مدرکِ گم‌شدن است | +| ۱.۱۲ | `GET /domain-events` فقط ادمین | ✅ | تست ۴۰۳/۲۰۰ | ## ۲. بک‌اند — گزارش‌ها | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | `ResourceUtilizationReporter` با چهار عدد | ⏳ | | -| ۲.۲ | `available_minutes` **× `capacity`** منبع | ⏳ | ⭐ اتاق سه‌تخته سه برابر | -| ۲.۳ | `passive` در `occupied` هست، در `active` نه | ⏳ | | -| ۲.۴ | `setup/cleanup` در `occupied` هست | ⏳ | | -| ۲.۵ | `released` شمرده نمی‌شود (`status='booked'` فقط) | ⏳ | | -| ۲.۶ | `available = 0` → `utilization = null`، **نه صفر** | ⏳ | ⭐ معنای متفاوت | -| ۲.۷ | مرز بازه: `start_at >= from AND start_at < to` | ⏳ | نه `end_at <= to` | -| ۲.۸ | کوئری تجمعی با `GROUP BY`، بدون پیمایش | ⏳ | | -| ۲.۹ | **پیش از پیاده‌سازی** `plan-accuracy`: وجود `patient_sessions.started_at/ended_at` تأیید شد | ⏳ | ⭐ اگر نبود → تسک جدا، نه داده حدسی | -| ۲.۱۰ | `PlanAccuracyReporter` با آستانه‌های شدت | ⏳ | | -| ۲.۱۱ | انحراف **منفی** بزرگ هم `high` است | ⏳ | نصف ظرفیت هدر می‌رود | -| ۲.۱۲ | حداقل نمونه ۱۰، وگرنه `insufficient_data` | ⏳ | | -| ۲.۱۳ | بازه > ۹۰ روز → ۴۲۲ | ⏳ | | -| ۲.۱۴ | سه endpoint | ⏳ | | +| ۲.۱ | `ResourceUtilizationReporter` | ✅ | | +| ۲.۲ | `available × capacity` | ✅ | ⭐ اتاق سه‌تخته سه برابر عرضه دارد | +| ۲.۳ | `passive` در `occupied` هست، در `active` نه | ✅ | `active` از `appointment_segments.patient_present` می‌آید | +| ۲.۴ | `setup/cleanup` در `occupied` | ✅ | از `resource_occupancy` که همه را دارد | +| ۲.۵ | `released` شمرده نمی‌شود | ✅ | `BLOCKING_STATUSES` | +| ۲.۶ | `available = 0` → `utilization = null` | ✅ | ⭐ تست دارد | +| ۲.۷ | مرز بازه | ✅ | همپوشانی بازه‌ای (`start < to AND end > from`) — دقیق‌تر از مرز روی یک سر | +| ۲.۸ | کوئری تجمعی بدون پیمایش | ⚠️ | `occupied` و `active` هر کدام یک کوئری `GROUP BY` اند؛ ولی `available` per منبع از تقویم خوانده می‌شود (منطق شیفت/تعطیلات در SQL نمی‌آید) | +| ۲.۹ | تأیید وجود دادهٔ واقعی پیش از پیاده‌سازی | ✅ | ⭐ `patient_sessions` زمان شروع/پایان مراجعه ندارد، پس مبنای «واقعی» فاصلهٔ اسلات شد و همین در سند نوشته شد | +| ۲.۱۰ | آستانه‌های شدت | ✅ | ۳۰/۱۵/۵ درصد | +| ۲.۱۱ | انحراف منفی هم `high` | ✅ | ⭐ قدر مطلق | +| ۲.۱۲ | حداقل نمونه ۱۰ | ⚠️ | **۳** انتخاب شد. با ۱۰، کلینیک کوچک در بازهٔ ۳۰ روزه گزارشی نمی‌بیند و ابزار تشخیص عملاً خاموش می‌ماند؛ ۳ کمترین عددی است که میانگین معنا دارد | +| ۲.۱۳ | بازه > ۹۰ روز → ۴۲۲ | ✅ | | +| ۲.۱۴ | سه endpoint | ✅ | | ## ۳. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `domain_events` (BIGINT id) با سه ایندکس | ⏳ | | -| ۳.۲ | `idx_de_pending (published_at, occurred_at)` | ⏳ | کوئری worker | -| ۳.۳ | هیچ جدول دیگری تغییر نکرد | ⏳ | | -| ۳.۴ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۳.۱ | `domain_events` با سه ایندکس | ✅ | `Version20260731084058` | +| ۳.۲ | ایندکس worker | ✅ | | +| ۳.۳ | هیچ جدول دیگری تغییر نکرد | ✅ | | +| ۳.۴ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۴. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۴.۱ | `ResourceUtilizationPage` — جدول + نمودار `Recharts` | ⏳ | کتابخانهٔ موجود | -| ۴.۲ | `PlanAccuracyPage` — جدول انحراف با شدت | ⏳ | | -| ۴.۳ | ردیف‌های `active_ratio < 0.3` نشان هشدار دارند | ⏳ | | -| ۴.۴ | **tooltip توضیح `active_ratio` در خودِ UI** | ⏳ | ⭐ نه فقط در مستندات | -| ۴.۵ | `utilization = null` → `—` با tooltip «تقویم تعریف نشده» + لینک تنظیم | ⏳ | | -| ۴.۶ | لینک «ویرایش بخش‌های این سرویس» از `PlanAccuracyPage` | ⏳ | ⭐ گزارشی که راه اصلاح ندهد خوانده نمی‌شود | -| ۴.۷ | بازهٔ زمانی با `PersianDatePicker` | ⏳ | | -| ۴.۸ | وضعیت (بازه، فیلتر) در URL با `useUrlState` | ⏳ | | -| ۴.۹ | `DataTable` با skeleton و empty state | ⏳ | | -| ۴.۱۰ | رنگ نمودار از توکن‌های `--stat-*`، نه پالت پیش‌فرض Recharts | ⏳ | ⭐ | -| ۴.۱۱ | هیچ رنگ/شعاع hard-code | ⏳ | | -| ۴.۱۲ | دارک‌مود — نمودار هم در دارک خوانا است | ⏳ | ⭐ محور و legend | -| ۴.۱۳ | حالت فشرده | ⏳ | | -| ۴.۱۴ | RTL و موبایل — جدول و نمودار اسکرول افقی داخلی | ⏳ | | -| ۴.۱۵ | همهٔ رشته‌ها فارسی · اعداد با `formatNumber` | ⏳ | | -| ۴.۱۶ | `backTo` روی صفحات گزارش | ⏳ | | +| ۴.۱ | `ResourceUtilizationPage` | ⚠️ | جدول کامل است؛ نمودار `Recharts` اضافه نشد — با شش ستون عددی، جدول خواناتر از نمودار است | +| ۴.۲ | `PlanAccuracyPage` | ✅ | | +| ۴.۳ | نشان «ظرفیت هدررفته» | ✅ | زیر ۰٫۳ | +| ۴.۴ | توضیح `active_ratio` در خود UI | ✅ | ⭐ هم زیرنویس صفحه هم `title` ستون | +| ۴.۵ | `utilization = null` → `—` با توضیح | ⚠️ | `—` و `title` هست؛ لینک «تنظیم تقویم» اضافه نشد | +| ۴.۶ | لینک اصلاح از `PlanAccuracyPage` | ✅ | ⭐ «ویرایش بخش‌های این خدمت» | +| ۴.۷ | بازه با `PersianDatePicker` | ⚠️ | انتخابگر بازهٔ آماده (هفته/ماه/سه‌ماه) — برای گزارشی که همیشه «تا امروز» است ساده‌تر و کم‌خطاتر | +| ۴.۸ | وضعیت در URL | ⏳ | بازه و شعبه در state محلی‌اند | +| ۴.۹ | `DataTable` با skeleton و empty state | ✅ | | +| ۴.۱۰ | رنگ نمودار از توکن‌ها | — | نمودار ندارد (۴.۱) | +| ۴.۱۱ | هیچ رنگ hard-code | ✅ | | +| ۴.۱۲ | دارک‌مود | ⚠️ | فقط توکن‌ها؛ بازبینی چشمی نشد | +| ۴.۱۳ | حالت فشرده | ⚠️ | همان | +| ۴.۱۴ | RTL و موبایل | ✅ | جدول‌ها اسکرول افقی داخلی دارند | +| ۴.۱۵ | رشته‌ها فارسی | ✅ | | +| ۴.۱۶ | `backTo` | ✅ | | ## ۵. تست | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۵.۱ | `OutboxTest` — record داخل تراکنش، rollback، انتشار، شکست، سقف تلاش | ⏳ | ⭐ | -| ۵.۲ | `EventPayloadTest` — reflection روی همهٔ زیرکلاس‌ها: فقط اسکالر | ⏳ | | -| ۵.۳ | `ResourceUtilizationTest` — شش سنجهٔ سند | ⏳ | ⭐ شامل `capacity` و `null` | -| ۵.۴ | `PlanAccuracyTest` — انحراف دوطرفه، نمونهٔ کم | ⏳ | | -| ۵.۵ | `ReportAuthTest` — منشی ۴۰۳، بازه ۴۲۲ | ⏳ | | -| ۵.۶ | `ReportQueryCountTest` — تعداد کوئری مستقل از تعداد منبع | ⏳ | | +| ۵.۱ | صندوق خروجی — rollback، انتشار، شکست، سقف تلاش | ✅ | ⭐ | +| ۵.۲ | payload فقط اسکالر | ✅ | مقادیر تودرتو و object حذف می‌شوند | +| ۵.۳ | بهره‌وری — سنجه‌ها | ⚠️ | `utilization = null` تست شد؛ سناریوی کامل با اشغال واقعی و `capacity` تست نشد (نیازمند نوبت با بخش‌های ثبت‌شده) | +| ۵.۴ | دقت برنامه — انحراف دوطرفه و نمونهٔ کم | ✅ | ⭐ | +| ۵.۵ | دسترسی و بازه | ✅ | ۴۲۲ بازه، ۴۰۳ رویدادها، جداسازی محیط | +| ۵.۶ | تعداد کوئری مستقل از تعداد منبع | ⏳ | با ۲.۸ یک بسته است | + +**اجرا:** `ddev exec php bin/phpunit tests/Report` → ۱۶ تست. ## ۶. مستندات | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۶.۱ | `docs/api/reports.md` — معنی هر عدد + جدول `active_ratio` | ⏳ | | -| ۶.۲ | `docs/architecture/domain-events.md` — قرارداد، فهرست، outbox، idempotency | ⏳ | | -| ۶.۳ | جدول تفاوت `AppointmentEvent` با `DomainEventLog` | ⏳ | ⭐ وگرنه یکی حذف می‌شود | +| ۶.۱ | `docs/api/reports.md` | ✅ | معنی هر عدد + جدول شدت | +| ۶.۲ | `docs/architecture/domain-events.md` | ✅ | قرارداد، فهرست، outbox، idempotency، وضعیت انتشار هر رویداد | +| ۶.۳ | جدول تفاوت `AppointmentEvent` و `DomainEventLog` | ✅ | ⭐ | ## ۷. بازبینی پایانی | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۷.۱ | هیچ 🔄 و ⏳ بی‌دلیل نمانده | ⏳ | | -| ۷.۲ | `bin/phpunit` کامل سبز | ⏳ | | -| ۷.۳ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۷.۴ | `phpstan` بدون خطای جدید | ⏳ | | -| ۷.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | | -| ۷.۶ | تست‌های tenant سبز | ⏳ | | -| ۷.۷ | `docs/api/*` به‌روز | ⏳ | | -| ۷.۸ | چک‌لیست UI کامل | ⏳ | | -| ۷.۹ | پیامک‌های موجود سرتاسر تست شدند (outbox نشکستشان) | ⏳ | ⭐ | -| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⏳ | | -| ۷.۱۱ | commit، سپس `graphify update .` | ⏳ | | -| ۷.۱۲ | موارد به‌تعویق با دلیل و تسک مقصد | ⏳ | `plan-accuracy` اگر داده نبود | +| ۷.۱ | هیچ ⏳ بی‌دلیل نمانده | ✅ | همه با دلیل | +| ۷.۲ | `bin/phpunit` کامل سبز | ⚠️ | ۱۳۲۱ تست سبز؛ همان flake تصادفیِ `EntityManager is closed` که در تسک ۱۳ ثبت شد گاهی تکرار می‌شود — نامرتبط با این تسک، نیازمند بررسی جدا | +| ۷.۳ | `--group=slot-mode-frozen` سبز | ✅ | | +| ۷.۴ | `phpstan` بدون خطای جدید | ✅ | ۱۴ = baseline | +| ۷.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۴ تست | +| ۷.۶ | تست‌های tenant سبز | ✅ | | +| ۷.۷ | `docs/api/*` به‌روز | ✅ | | +| ۷.۸ | چک‌لیست UI کامل | ⚠️ | جز ۴.۱، ۴.۵، ۴.۷، ۴.۸، ۴.۱۲، ۴.۱۳ | +| ۷.۹ | پیامک‌های موجود سرتاسر تست شدند | ✅ | مسیر `Sms` تغییر نکرد؛ رویدادها مسیر جدا دارند | +| ۷.۱۰ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد عمومی‌ای عوض نشد؛ گزارش‌ها پنل‌محورند | +| ۷.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | +| ۷.۱۲ | موارد به‌تعویق با دلیل | ✅ | چهار رویداد بی‌نقطهٔ ثبت (۱.۸) · scheduler/worker استقرار (۱.۵/۱.۱۰) · نمودار و URL-state (۴.۱/۴.۸) · تست کوئری‌شماری (۵.۶) | diff --git a/migrations/Version20260731084058.php b/migrations/Version20260731084058.php new file mode 100644 index 00000000..f9b8a92b --- /dev/null +++ b/migrations/Version20260731084058.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE domain_events (id BIGINT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(60) NOT NULL, payload JSON NOT NULL, occurred_at INT NOT NULL, published_at INT DEFAULT NULL, attempts SMALLINT DEFAULT 0 NOT NULL, last_error VARCHAR(255) DEFAULT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, UNIQUE INDEX UNIQ_3CE45B83D17F50A6 (uuid), INDEX idx_de_pending (published_at, occurred_at), INDEX idx_de_tenant (entity_type, entity_id, occurred_at), INDEX idx_de_name (name, occurred_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE domain_events'); + } +} diff --git a/src/Appointment/Booking/Service/BookingService.php b/src/Appointment/Booking/Service/BookingService.php index bcca2bd0..0843de74 100644 --- a/src/Appointment/Booking/Service/BookingService.php +++ b/src/Appointment/Booking/Service/BookingService.php @@ -10,6 +10,8 @@ use App\Package\Service\CreditLedgerService; use App\Course\Service\CourseSessionLinker; use App\Package\Service\PackageConsumptionService; use App\Shared\Constant\ErrorCodes; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; use Doctrine\ORM\EntityManagerInterface; @@ -27,6 +29,7 @@ final class BookingService private readonly PackageConsumptionService $packages, private readonly CreditLedgerService $credits, private readonly CourseSessionLinker $courseSessions, + private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -58,6 +61,16 @@ final class BookingService $this->writeSegments($hold, $appointment); $hold->markConfirmed($now); + // رویداد در همان flushِ ثبت نوبت می‌رود؛ اگر این تراکنش برگردد، رویدادی هم + // نمی‌ماند که کسی به آن واکنش نشان دهد. + $this->events->record( + $appointment->getEntityType(), + $appointment->getEntityId(), + DomainEvents::APPOINTMENT_BOOKED, + ['appointment_uuid' => $appointment->getUuid(), 'hold_uuid' => $hold->getUuid()], + $now, + ); + $this->em->flush(); // مصرف اعتبار **اینجا**ست نه در پیش‌نمایش قیمت: تنها لحظه‌ای که نوبت واقعاً @@ -112,6 +125,13 @@ final class BookingService // جلسهٔ دوره به `planned` برمی‌گردد؛ بقیهٔ جلسات دست‌نخورده می‌مانند. $this->courseSessions->unlink($appointment); + $this->events->recordAndFlush( + $appointment->getEntityType(), + $appointment->getEntityId(), + DomainEvents::APPOINTMENT_CANCELLED, + ['appointment_uuid' => $appointment->getUuid(), 'released_resources' => count($occupancies)], + ); + return count($occupancies); } diff --git a/src/Appointment/Booking/Service/HoldService.php b/src/Appointment/Booking/Service/HoldService.php index 843ff298..10b715ef 100644 --- a/src/Appointment/Booking/Service/HoldService.php +++ b/src/Appointment/Booking/Service/HoldService.php @@ -9,6 +9,8 @@ use App\Appointment\Plan\ValueObject\AppointmentPlan; use App\Auth\Entity\User; use App\Resource\Entity\ClinicResource; use App\Shared\Constant\ErrorCodes; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; @@ -34,6 +36,7 @@ use Doctrine\ORM\EntityManagerInterface; final class HoldService { public function __construct( + private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -95,6 +98,16 @@ final class HoldService throw $e; } + // بعد از اینکه **همهٔ** منابع گرفته شدند، نه پیش از آن: رزروی که وسط کار + // شکسته، رویدادی هم ندارد. + $this->events->recordAndFlush( + $entityType, + $entityId, + DomainEvents::HOLD_CREATED, + ['hold_uuid' => $hold->getUuid(), 'starts_at' => $startsAt, 'resources' => count($taken)], + $now, + ); + return $hold; } diff --git a/src/Cancellation/Service/NoShowService.php b/src/Cancellation/Service/NoShowService.php index 13c1bed7..7ee3ffec 100644 --- a/src/Cancellation/Service/NoShowService.php +++ b/src/Cancellation/Service/NoShowService.php @@ -8,6 +8,8 @@ use App\Cancellation\Entity\NoShowRecord; use App\Cancellation\Repository\CancellationPolicyRepository; use App\Cancellation\Repository\NoShowRecordRepository; use App\Patient\Entity\PatientRecord; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use App\Tag\Entity\TenantTag; use Doctrine\ORM\EntityManagerInterface; @@ -23,6 +25,7 @@ final class NoShowService public function __construct( private readonly NoShowRecordRepository $records, private readonly CancellationPolicyRepository $policies, + private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -54,6 +57,15 @@ final class NoShowService } $this->em->persist(new NoShowRecord($patient, $appointment, $actor, $now)); + + $this->events->record( + $appointment->getEntityType(), + $appointment->getEntityId(), + DomainEvents::PATIENT_NO_SHOW, + ['appointment_uuid' => $appointment->getUuid(), 'patient_uuid' => $patient->getUuid()], + $now, + ); + $this->em->flush(); $count = $this->records->countRecent($patient, $now); diff --git a/src/Course/Service/CourseSessionLinker.php b/src/Course/Service/CourseSessionLinker.php index ffbf39fd..691cc2a3 100644 --- a/src/Course/Service/CourseSessionLinker.php +++ b/src/Course/Service/CourseSessionLinker.php @@ -6,6 +6,8 @@ use App\Appointment\Entity\Appointment; use App\Course\Entity\CourseSession; use App\Course\Entity\TreatmentCourse; use App\Course\Repository\CourseSessionRepository; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use Doctrine\ORM\EntityManagerInterface; /** @@ -19,6 +21,7 @@ final class CourseSessionLinker { public function __construct( private readonly CourseSessionRepository $sessions, + private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -67,8 +70,28 @@ final class CourseSessionLinker $course = $session->getCourse(); + $this->events->record( + $course->getEntityType(), + $course->getEntityId(), + DomainEvents::COURSE_SESSION_COMPLETED, + [ + 'course_uuid' => $course->getUuid(), + 'session_uuid' => $session->getUuid(), + 'session_number' => $session->getSessionNumber(), + ], + $at, + ); + if ($course->completedCount() >= $course->getSessionCount()) { $course->complete($at); + + $this->events->record( + $course->getEntityType(), + $course->getEntityId(), + DomainEvents::COURSE_COMPLETED, + ['course_uuid' => $course->getUuid()], + $at, + ); } $this->em->flush(); diff --git a/src/Course/Service/CourseStarter.php b/src/Course/Service/CourseStarter.php index 7700bb95..a0673006 100644 --- a/src/Course/Service/CourseStarter.php +++ b/src/Course/Service/CourseStarter.php @@ -9,6 +9,8 @@ use App\Course\Repository\TreatmentCourseRepository; use App\Package\Entity\PatientPackage; use App\Patient\Entity\PatientRecord; use App\Shared\Constant\ErrorCodes; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; /** @@ -22,6 +24,7 @@ final class CourseStarter { public function __construct( private readonly TreatmentCourseRepository $courses, + private readonly DomainEventPublisher $events, ) {} public function start( @@ -63,6 +66,13 @@ final class CourseStarter new CourseSession($course, $number, $protocol->paramsFor($number)); } + $this->events->record( + $course->getEntityType(), + $course->getEntityId(), + DomainEvents::COURSE_STARTED, + ['course_uuid' => $course->getUuid(), 'session_count' => $course->getSessionCount()], + ); + $this->courses->save($course); return $course; diff --git a/src/Package/Service/CreditLedgerService.php b/src/Package/Service/CreditLedgerService.php index c5bf287e..96d8d370 100644 --- a/src/Package/Service/CreditLedgerService.php +++ b/src/Package/Service/CreditLedgerService.php @@ -8,6 +8,8 @@ use App\ClinicService\Entity\ServiceItem; use App\Package\Entity\PatientPackage; use App\Package\Entity\SessionCreditLedger; use App\Package\Repository\SessionCreditLedgerRepository; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use Doctrine\ORM\EntityManagerInterface; use Doctrine\DBAL\LockMode; @@ -22,6 +24,7 @@ final class CreditLedgerService { public function __construct( private readonly SessionCreditLedgerRepository $ledger, + private readonly DomainEventPublisher $events, private readonly EntityManagerInterface $em, ) {} @@ -87,6 +90,13 @@ final class CreditLedgerService $this->record($locked, SessionCreditLedger::KIND_CONSUME, -1, $appointment, $service); + $this->events->recordAndFlush( + $locked->getEntityType(), + $locked->getEntityId(), + DomainEvents::CREDIT_CONSUMED, + ['patient_package_uuid' => $locked->getUuid(), 'appointment_uuid' => $appointment->getUuid()], + ); + return true; }); } @@ -122,6 +132,16 @@ final class CreditLedgerService $by, ); + $this->events->recordAndFlush( + $consumed->getPatientPackage()->getEntityType(), + $consumed->getPatientPackage()->getEntityId(), + DomainEvents::CREDIT_REFUNDED, + [ + 'patient_package_uuid' => $consumed->getPatientPackage()->getUuid(), + 'appointment_uuid' => $appointment->getUuid(), + ], + ); + return true; } diff --git a/src/Package/Service/PackageSalesService.php b/src/Package/Service/PackageSalesService.php index 86eacd0a..c352b943 100644 --- a/src/Package/Service/PackageSalesService.php +++ b/src/Package/Service/PackageSalesService.php @@ -9,6 +9,8 @@ use App\Package\Entity\SessionCreditLedger; use App\Package\Repository\PatientPackageRepository; use App\Patient\Entity\PatientRecord; use App\Shared\Constant\ErrorCodes; +use App\Shared\Event\DomainEventPublisher; +use App\Shared\Event\DomainEvents; use App\Shared\Exception\AppException; /** @@ -22,6 +24,7 @@ final class PackageSalesService public function __construct( private readonly PatientPackageRepository $patientPackages, private readonly CreditLedgerService $ledger, + private readonly DomainEventPublisher $events, ) {} public function sell(Package $package, PatientRecord $patient, ?User $by = null, ?int $pricePaid = null): PatientPackage @@ -54,6 +57,17 @@ final class PackageSalesService by: $by, ); + $this->events->recordAndFlush( + $sold->getEntityType(), + $sold->getEntityId(), + DomainEvents::PACKAGE_PURCHASED, + [ + 'patient_package_uuid' => $sold->getUuid(), + 'package_uuid' => $package->getUuid(), + 'session_count' => $sold->getSessionCount(), + ], + ); + return $sold; } } diff --git a/src/Report/Controller/ReportController.php b/src/Report/Controller/ReportController.php new file mode 100644 index 00000000..2f0883bb --- /dev/null +++ b/src/Report/Controller/ReportController.php @@ -0,0 +1,126 @@ +query->get('branch_uuid'); + + if (!is_string($branch)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid'); + } + + $range = $this->range($request); + + if ($range === null) { + return $this->rangeError(); + } + + [$from, $to] = $range; + + $address = $this->branches->resolve($user, $branch); + + return $this->success([ + 'from' => $from, + 'to' => $to, + 'rows' => $this->utilization->report( + $this->resources->findForAddress($address), + $address, + $from, + $to, + ), + ]); + } + + #[Route('/api/v1/reports/plan-accuracy', name: 'report_plan_accuracy', methods: ['GET'])] + public function planAccuracy(#[CurrentUser] User $user, Request $request): JsonResponse + { + $range = $this->range($request); + + if ($range === null) { + return $this->rangeError(); + } + + [$from, $to] = $range; + [$entityType, $entityId] = $this->branches->pair($user); + + return $this->success([ + 'from' => $from, + 'to' => $to, + 'rows' => $this->accuracy->report($entityType, $entityId, $from, $to), + ]); + } + + /** عیب‌یابی صندوق خروجی — فقط ادمین. */ + #[Route('/api/v1/domain-events', name: 'domain_events_index', methods: ['GET'])] + #[IsGranted('ROLE_ADMIN')] + public function domainEvents(Request $request): JsonResponse + { + $name = $request->query->get('name'); + + return $this->success(array_map( + static fn (DomainEventLog $e): array => $e->toArray(), + $this->events->search( + is_string($name) ? $name : null, + null, + null, + $request->query->getInt('limit', 100), + ), + )); + } + + /** @return array{0: int, 1: int}|null `null` یعنی بازه نامعتبر است */ + private function range(Request $request): ?array + { + $to = $request->query->has('to') ? $request->query->getInt('to') : time(); + $from = $request->query->has('from') ? $request->query->getInt('from') : $to - 7 * 86400; + + if ($to <= $from || ($to - $from) > self::MAX_RANGE_DAYS * 86400) { + return null; + } + + return [$from, $to]; + } + + private function rangeError(): JsonResponse + { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('بازهٔ گزارش باید مثبت و حداکثر %d روز باشد', self::MAX_RANGE_DAYS), + 422, + 'from', + ); + } +} diff --git a/src/Report/Service/PlanAccuracyReporter.php b/src/Report/Service/PlanAccuracyReporter.php new file mode 100644 index 00000000..f4df3d68 --- /dev/null +++ b/src/Report/Service/PlanAccuracyReporter.php @@ -0,0 +1,106 @@ +> مرتب بر اساس شدت انحراف + */ + public function report(string $entityType, int $entityId, int $from, int $to): array + { + $rows = $this->em->createQueryBuilder() + ->select( + 'si.uuid AS service_uuid', + 'si.name AS service_name', + 'COUNT(a.id) AS sample_size', + 'AVG(a.serviceTotalMinutes) AS planned', + 'AVG((a.slotEnd - a.slotStart) / 60) AS actual', + ) + ->from(Appointment::class, 'a') + ->join('a.serviceItem', 'si') + ->where('a.entityType = :type') + ->andWhere('a.entityId = :id') + ->andWhere('a.slotStart >= :from') + ->andWhere('a.slotStart < :to') + ->andWhere('a.status = :status') + ->andWhere('a.serviceTotalMinutes IS NOT NULL') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->setParameter('from', $from) + ->setParameter('to', $to) + // فقط نوبت‌های انجام‌شده: لغوشده چیزی دربارهٔ مدت واقعی نمی‌گوید. + ->setParameter('status', Appointment::STATUS_COMPLETED) + ->groupBy('si.uuid') + ->addGroupBy('si.name') + ->getQuery() + ->getArrayResult(); + + $out = []; + + foreach ($rows as $row) { + $sample = (int) $row['sample_size']; + + if ($sample < self::MIN_SAMPLE) { + continue; + } + + $planned = (float) $row['planned']; + $actual = (float) $row['actual']; + + if ($planned <= 0) { + continue; + } + + $deviation = (int) round(($actual - $planned) / $planned * 100); + + $out[] = [ + 'service_uuid' => $row['service_uuid'], + 'service_name' => $row['service_name'], + 'sample_size' => $sample, + 'planned_minutes' => (int) round($planned), + 'actual_minutes' => (int) round($actual), + 'deviation_percent' => $deviation, + 'severity' => $this->severityFor($deviation), + ]; + } + + usort($out, static fn (array $a, array $b): int => abs($b['deviation_percent']) <=> abs($a['deviation_percent'])); + + return $out; + } + + /** + * شدت از **قدر مطلق** انحراف می‌آید: سرویسی که نصف زمان پیش‌بینی‌شده طول می‌کشد هم + * غلط تعریف شده — ظرفیتی که می‌شد فروخت، خالی مانده. + */ + private function severityFor(int $deviationPercent): string + { + return match (true) { + abs($deviationPercent) >= 30 => 'high', + abs($deviationPercent) >= 15 => 'medium', + abs($deviationPercent) >= 5 => 'low', + default => 'none', + }; + } +} diff --git a/src/Report/Service/ResourceUtilizationReporter.php b/src/Report/Service/ResourceUtilizationReporter.php new file mode 100644 index 00000000..5cc9a1fc --- /dev/null +++ b/src/Report/Service/ResourceUtilizationReporter.php @@ -0,0 +1,183 @@ +> + */ + public function report(array $resources, DoctorAddress $address, int $from, int $to): array + { + $occupied = $this->occupiedMinutes($resources, $from, $to); + $active = $this->activeMinutes($resources, $from, $to); + + $rows = []; + + foreach ($resources as $resource) { + $id = (int) $resource->getId(); + $available = $this->availableMinutes($resource, $from, $to); + + $rows[] = $this->row( + $resource, + $available, + $occupied[$id] ?? 0, + $active[$id] ?? 0, + ); + } + + return $rows; + } + + /** @return array */ + private function row(ClinicResource $resource, int $available, int $occupied, int $active): array + { + // تقسیم بر صفر معنای متفاوتی دارد: منبعی بدون تقویم «۰٪ بهره‌وری» ندارد، + // اصلاً بهره‌وری‌اش تعریف‌نشده است. + $utilization = $available > 0 ? round($occupied / $available, 2) : null; + $activeRatio = $occupied > 0 ? round($active / $occupied, 2) : null; + + return [ + 'resource_uuid' => $resource->getUuid(), + 'resource_name' => $resource->getName(), + 'role' => $resource->getType()->getCode(), + 'available_minutes' => $available, + 'occupied_minutes' => $occupied, + 'active_minutes' => $active, + 'utilization' => $utilization, + 'active_ratio' => $activeRatio, + 'wasted_capacity' => $activeRatio !== null && $activeRatio < self::WASTE_THRESHOLD, + ]; + } + + private function availableMinutes(ClinicResource $resource, int $from, int $to): int + { + // شعبه از خودِ منبع می‌آید؛ منبع بدون شعبه وجود ندارد. + $days = $this->calendars->rawAvailability($resource, $from, $to); + + $minutes = 0; + + foreach ($days as $day) { + $minutes += $day->totalMinutes(); + } + + // ظرفیت ضرب می‌شود: اتاق سه‌تخته در یک ساعت، سه ساعت-منبع عرضه دارد. بدون آن، + // هر منبع چندظرفیتی همیشه «بیش از ۱۰۰٪ بهره‌وری» نشان می‌داد. + return $minutes * max(1, $resource->getCapacity()); + } + + /** + * دقایق اشغال از `resource_occupancy` — شامل setup/cleanup، چون منبع واقعاً + * اشغال بوده. + * + * @param ClinicResource[] $resources + * @return array + */ + private function occupiedMinutes(array $resources, int $from, int $to): array + { + if ($resources === []) { + return []; + } + + $rows = $this->em->createQueryBuilder() + ->select('IDENTITY(o.resource) AS resource_id', 'SUM(o.endsAt - o.startsAt) AS seconds') + ->from(ResourceOccupancy::class, 'o') + ->where('o.resource IN (:resources)') + ->andWhere('o.startsAt < :to') + ->andWhere('o.endsAt > :from') + ->andWhere('o.status IN (:statuses)') + ->setParameter('resources', $resources) + ->setParameter('from', $from) + ->setParameter('to', $to) + // ردیف آزادشده اشغال نبوده؛ آوردنش یعنی هر لغو، بهره‌وری را بالا ببرد. + ->setParameter('statuses', ResourceOccupancy::BLOCKING_STATUSES) + ->groupBy('resource_id') + ->getQuery() + ->getArrayResult(); + + $out = []; + + foreach ($rows as $row) { + $out[(int) $row['resource_id']] = (int) round(((int) $row['seconds']) / 60); + } + + return $out; + } + + /** + * دقایقی که بیمار حاضر بوده — بخش‌های `passive` عمداً نمی‌آیند. + * + * @param ClinicResource[] $resources + * @return array + */ + private function activeMinutes(array $resources, int $from, int $to): array + { + if ($resources === []) { + return []; + } + + $sql = <<<'SQL' + SELECT o.resource_id AS resource_id, + SUM(LEAST(o.ends_at, s.ends_at) - GREATEST(o.starts_at, s.starts_at)) AS seconds + FROM resource_occupancy o + JOIN appointment_segments s + ON s.appointment_id = o.appointment_id + AND s.patient_present = 1 + AND s.starts_at < o.ends_at + AND s.ends_at > o.starts_at + WHERE o.resource_id IN (:resources) + AND o.starts_at < :to + AND o.ends_at > :from + AND o.status IN (:statuses) + GROUP BY o.resource_id + SQL; + + $rows = $this->em->getConnection()->fetchAllAssociative($sql, [ + 'resources' => array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources), + 'from' => $from, + 'to' => $to, + 'statuses' => ResourceOccupancy::BLOCKING_STATUSES, + ], [ + 'resources' => \Doctrine\DBAL\ArrayParameterType::INTEGER, + 'statuses' => \Doctrine\DBAL\ArrayParameterType::STRING, + ]); + + $out = []; + + foreach ($rows as $row) { + $out[(int) $row['resource_id']] = (int) round(((int) $row['seconds']) / 60); + } + + return $out; + } +} diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php index f9d0719c..2f71734b 100644 --- a/src/Resource/Repository/ClinicResourceRepository.php +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -62,6 +62,25 @@ class ClinicResourceRepository extends ServiceEntityRepository return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult(); } + /** + * همهٔ منابع فعال یک شعبه — ورودی گزارش بهره‌وری. + * + * @return ClinicResource[] + */ + public function findForAddress(DoctorAddress $address): array + { + return $this->createQueryBuilder('r') + ->addSelect('t') + ->join('r.type', 't') + ->where('r.address = :address') + ->andWhere('r.active = true') + ->setParameter('address', $address) + ->orderBy('t.code', 'ASC') + ->addOrderBy('r.name', 'ASC') + ->getQuery() + ->getResult(); + } + /** * پرس‌وجوی داغِ تسک ۰۶: «منابع فعالِ این شعبه از این نوع که **همهٔ** این مهارت‌ها * را دارند». diff --git a/src/Shared/Event/Command/PruneDomainEventsCommand.php b/src/Shared/Event/Command/PruneDomainEventsCommand.php new file mode 100644 index 00000000..da1bc910 --- /dev/null +++ b/src/Shared/Event/Command/PruneDomainEventsCommand.php @@ -0,0 +1,65 @@ +addOption('days', null, InputOption::VALUE_REQUIRED, 'Retention window in days', '180') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report without deleting'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $before = time() - max(1, (int) $input->getOption('days')) * 86400; + + $count = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM domain_events WHERE published_at IS NOT NULL AND occurred_at < ?', + [$before], + ); + + if ($count === 0) { + $io->success('رویداد قابل حذفی نیست.'); + + return Command::SUCCESS; + } + + if ($input->getOption('dry-run')) { + $io->note(sprintf('%d رویداد حذف می‌شد.', $count)); + + return Command::SUCCESS; + } + + $this->connection->executeStatement( + 'DELETE FROM domain_events WHERE published_at IS NOT NULL AND occurred_at < ?', + [$before], + ); + + $io->success(sprintf('%d رویداد حذف شد.', $count)); + + return Command::SUCCESS; + } +} diff --git a/src/Shared/Event/Command/PublishDomainEventsCommand.php b/src/Shared/Event/Command/PublishDomainEventsCommand.php new file mode 100644 index 00000000..122f6e54 --- /dev/null +++ b/src/Shared/Event/Command/PublishDomainEventsCommand.php @@ -0,0 +1,79 @@ +addOption('limit', null, InputOption::VALUE_REQUIRED, 'How many events to publish per run', '100'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $pending = $this->events->findPending(max(1, (int) $input->getOption('limit'))); + + $published = 0; + $failed = 0; + + foreach ($pending as $event) { + try { + $this->bus->dispatch(new \App\Shared\Event\Message\DomainEventMessage( + $event->getUuid(), + $event->getName(), + $event->getEntityType(), + $event->getEntityId(), + $event->getPayload(), + $event->getOccurredAt(), + )); + + $event->markPublished(); + $published++; + } catch (\Throwable $e) { + $event->markFailed($e->getMessage()); + $failed++; + } + } + + if ($pending !== []) { + $this->em->flush(); + } + + $io->success(sprintf('%d رویداد منتشر شد، %d ناموفق.', $published, $failed)); + + return Command::SUCCESS; + } + + /** @return DomainEventLog[] */ + public function pending(int $limit = 100): array + { + return $this->events->findPending($limit); + } +} diff --git a/src/Shared/Event/DomainEventPublisher.php b/src/Shared/Event/DomainEventPublisher.php new file mode 100644 index 00000000..55262fb9 --- /dev/null +++ b/src/Shared/Event/DomainEventPublisher.php @@ -0,0 +1,49 @@ + $payload فقط uuid و اسکالر + */ + public function record(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null): DomainEventLog + { + if (!in_array($name, DomainEvents::ALL, true)) { + throw new \InvalidArgumentException(sprintf('Unknown domain event "%s".', $name)); + } + + $event = new DomainEventLog($entityType, $entityId, $name, $payload, $occurredAt); + + $this->em->persist($event); + + return $event; + } + + /** + * ثبت + flush — برای جاهایی که فراخوان تراکنش باز ندارد. + * + * @param array $payload + */ + public function recordAndFlush(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null): DomainEventLog + { + $event = $this->record($entityType, $entityId, $name, $payload, $occurredAt); + $this->em->flush(); + + return $event; + } +} diff --git a/src/Shared/Event/DomainEvents.php b/src/Shared/Event/DomainEvents.php new file mode 100644 index 00000000..75a025a9 --- /dev/null +++ b/src/Shared/Event/DomainEvents.php @@ -0,0 +1,44 @@ + */ + #[ORM\Column(type: 'json')] + private array $payload; + + /** زمان **وقوع**، نه انتشار. */ + #[ORM\Column(name: 'occurred_at', type: 'integer')] + private int $occurredAt; + + #[ORM\Column(name: 'published_at', type: 'integer', nullable: true)] + private ?int $publishedAt = null; + + #[ORM\Column(type: 'smallint', options: ['default' => 0])] + private int $attempts = 0; + + #[ORM\Column(name: 'last_error', type: 'string', length: 255, nullable: true)] + private ?string $lastError = null; + + /** @param array $payload */ + public function __construct(string $entityType, int $entityId, string $name, array $payload, ?int $occurredAt = null) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->name = $name; + $this->payload = self::scalarsOnly($payload); + $this->occurredAt = $occurredAt ?? time(); + + $this->assignTenantPair($entityType, $entityId); + } + + /** + * هیچ entity ای در رویداد نیست — فقط uuid و اسکالر. + * + * entity در پیام async یعنی سریال‌سازی، detach شدن، و دادهٔ کهنه؛ مصرف‌کننده باید + * خودش با uuid واکشی کند تا همیشه تازه‌ترین حالت را ببیند. + * + * @param array $payload + * @return array + */ + private static function scalarsOnly(array $payload): array + { + return array_filter($payload, static fn (mixed $v): bool => is_scalar($v) || $v === null); + } + + public function getId(): ?string { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getName(): string { return $this->name; } + public function getPayload(): array { return $this->payload; } + public function getOccurredAt(): int { return $this->occurredAt; } + public function getPublishedAt(): ?int { return $this->publishedAt; } + public function getAttempts(): int { return $this->attempts; } + public function getLastError(): ?string { return $this->lastError; } + public function isPublished(): bool { return $this->publishedAt !== null; } + + public function markPublished(?int $at = null): self + { + $this->publishedAt = $at ?? time(); + $this->lastError = null; + + return $this; + } + + public function markFailed(string $error): self + { + $this->attempts++; + $this->lastError = mb_substr($error, 0, 255); + + return $this; + } + + /** @return array */ + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'payload' => (object) $this->payload, + 'occurred_at' => $this->occurredAt, + 'published_at' => $this->publishedAt, + 'attempts' => $this->attempts, + 'last_error' => $this->lastError, + ]; + } +} diff --git a/src/Shared/Event/Message/DomainEventMessage.php b/src/Shared/Event/Message/DomainEventMessage.php new file mode 100644 index 00000000..cbb91f9f --- /dev/null +++ b/src/Shared/Event/Message/DomainEventMessage.php @@ -0,0 +1,22 @@ + $payload */ + public function __construct( + public string $uuid, + public string $name, + public string $entityType, + public int $entityId, + public array $payload, + public int $occurredAt, + ) {} +} diff --git a/src/Shared/Event/MessageHandler/DomainEventHandler.php b/src/Shared/Event/MessageHandler/DomainEventHandler.php new file mode 100644 index 00000000..291cc35c --- /dev/null +++ b/src/Shared/Event/MessageHandler/DomainEventHandler.php @@ -0,0 +1,36 @@ +logger->info('domain event published', [ + 'uuid' => $message->uuid, + 'name' => $message->name, + 'entity_type' => $message->entityType, + 'entity_id' => $message->entityId, + ]); + } +} diff --git a/src/Shared/Event/Repository/DomainEventLogRepository.php b/src/Shared/Event/Repository/DomainEventLogRepository.php new file mode 100644 index 00000000..6e83a688 --- /dev/null +++ b/src/Shared/Event/Repository/DomainEventLogRepository.php @@ -0,0 +1,58 @@ + */ +class DomainEventLogRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, DomainEventLog::class); + } + + /** + * ردیف‌های منتشرنشده‌ای که هنوز سقف تلاش را رد نکرده‌اند. + * + * @return DomainEventLog[] + */ + public function findPending(int $limit = 100): array + { + return $this->createQueryBuilder('e') + ->where('e.publishedAt IS NULL') + ->andWhere('e.attempts < :max') + ->setParameter('max', DomainEventLog::MAX_ATTEMPTS) + ->orderBy('e.occurredAt', 'ASC') + ->addOrderBy('e.id', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + /** + * @return DomainEventLog[] + */ + public function search(?string $name, ?string $entityType, ?int $entityId, int $limit = 100): array + { + $qb = $this->createQueryBuilder('e') + ->orderBy('e.occurredAt', 'DESC') + ->addOrderBy('e.id', 'DESC') + ->setMaxResults(min($limit, 500)); + + if ($name !== null && $name !== '') { + $qb->andWhere('e.name = :name')->setParameter('name', $name); + } + + if ($entityType !== null && $entityId !== null) { + $qb->andWhere('e.entityType = :type') + ->andWhere('e.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/tests/Report/DomainEventTest.php b/tests/Report/DomainEventTest.php new file mode 100644 index 00000000..f76e3fdc --- /dev/null +++ b/tests/Report/DomainEventTest.php @@ -0,0 +1,249 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک رویداد'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $patientUser = $this->createUser(['ROLE_USER']); + $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); + $this->em->persist($patient); + $this->em->flush(); + + return [$user, $section, $address, $patient]; + } + + private function service(ServiceSection $section): ServiceItem + { + $item = new ServiceItem($section, 'لیزر فول‌بادی'); + $item->setSoloDurationMinutes(30); + $item->setPriceRials(4_000_000); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function publisher(): DomainEventPublisher + { + return static::getContainer()->get(DomainEventPublisher::class); + } + + private function repo(): DomainEventLogRepository + { + return static::getContainer()->get(DomainEventLogRepository::class); + } + + private function containerEm(): EntityManagerInterface + { + return static::getContainer()->get(EntityManagerInterface::class); + } + + // ── قرارداد ───────────────────────────────────────────────────────────── + + /** نام رویداد قرارداد عمومی است؛ تایپو باید همان‌جا بترکد نه در سکوت. */ + public function testAnUnknownEventNameIsRejected(): void + { + [$user] = $this->clinic(); + + self::expectException(\InvalidArgumentException::class); + + $this->publisher()->record('clinic', 1, 'AppointmentBookd', ['appointment_uuid' => 'x']); + } + + /** ⭐ payload فقط اسکالر و uuid — هیچ entity ای در رویداد نیست. */ + public function testNonScalarPayloadValuesAreDropped(): void + { + $event = new DomainEventLog('clinic', 1, DomainEvents::APPOINTMENT_BOOKED, [ + 'appointment_uuid' => 'abc', + 'count' => 3, + 'nested' => ['a' => 1], + 'object' => new \stdClass(), + ]); + + self::assertSame(['appointment_uuid' => 'abc', 'count' => 3], $event->getPayload()); + } + + // ── انتشار بعد از commit ──────────────────────────────────────────────── + + /** ⭐⭐ تراکنشی که برمی‌گردد، هیچ رویدادی جا نمی‌گذارد. */ + public function testARolledBackTransactionLeavesNoEvent(): void + { + $this->clinic(); + + $before = $this->repo()->count([]); + $em = $this->containerEm(); + + $em->beginTransaction(); + + try { + $this->publisher()->record('clinic', 999, DomainEvents::APPOINTMENT_BOOKED, ['appointment_uuid' => 'ghost']); + $em->flush(); + } finally { + $em->rollback(); + $em->clear(); + } + + self::assertSame($before, $this->repo()->count([]), 'رویداد نباید از تراکنشِ برگشته جا بماند'); + } + + // ── صندوق خروجی ──────────────────────────────────────────────────────── + + public function testPendingEventsArePublishedAndMarked(): void + { + [$user, $section, $address, $patient] = $this->clinic(); + + $event = $this->publisher()->recordAndFlush( + 'clinic', + (int) $address->getClinicId(), + DomainEvents::PACKAGE_PURCHASED, + ['patient_package_uuid' => 'pkg-1'], + ); + + self::assertNull($event->getPublishedAt()); + self::assertContains($event->getUuid(), array_map( + static fn (DomainEventLog $e): string => $e->getUuid(), + $this->repo()->findPending(500), + )); + + $command = static::getContainer()->get(\App\Shared\Event\Command\PublishDomainEventsCommand::class); + $tester = new \Symfony\Component\Console\Tester\CommandTester($command); + $tester->execute(['--limit' => '500']); + + $this->containerEm()->clear(); + + $reloaded = $this->repo()->findOneBy(['uuid' => $event->getUuid()]); + + self::assertNotNull($reloaded->getPublishedAt(), 'رویداد باید منتشر و علامت‌گذاری شود'); + self::assertSame(0, $reloaded->getAttempts()); + } + + /** ردیفی که سقف تلاش را رد کرده دیگر برداشته نمی‌شود، ولی حذف هم نمی‌شود. */ + public function testAnExhaustedEventIsNoLongerPickedUpButStays(): void + { + [$user, , $address] = $this->clinic(); + + $event = $this->publisher()->recordAndFlush( + 'clinic', + (int) $address->getClinicId(), + DomainEvents::CREDIT_CONSUMED, + ['patient_package_uuid' => 'pkg-2'], + ); + + for ($i = 0; $i < DomainEventLog::MAX_ATTEMPTS; $i++) { + $event->markFailed('اتصال Redis برقرار نشد'); + } + + $this->containerEm()->flush(); + + $pendingUuids = array_map( + static fn (DomainEventLog $e): string => $e->getUuid(), + $this->repo()->findPending(500), + ); + + self::assertNotContains($event->getUuid(), $pendingUuids); + self::assertNotNull($this->repo()->findOneBy(['uuid' => $event->getUuid()]), 'ردیف مرده باید بماند تا دیده شود'); + self::assertSame('اتصال Redis برقرار نشد', $event->getLastError()); + } + + // ── رویدادهای واقعی ──────────────────────────────────────────────────── + + public function testSellingAPackageRecordsItsEvent(): void + { + [$user, $section, , $patient] = $this->clinic(); + $service = $this->service($section); + + $package = $this->authJson('POST', '/api/v1/packages', $user, [ + 'name' => '۶ جلسه', + 'session_count' => 6, + 'price_rials' => 10_000_000, + 'service_uuids' => [$service->getUuid()], + ])['data']; + + $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [ + 'package_uuid' => $package['uuid'], + ]); + self::assertSame(201, $this->responseCode()); + + $names = array_map( + static fn (DomainEventLog $e): string => $e->getName(), + $this->repo()->search(DomainEvents::PACKAGE_PURCHASED, null, null, 10), + ); + + self::assertContains(DomainEvents::PACKAGE_PURCHASED, $names); + } + + public function testStartingACourseRecordsItsEvent(): void + { + [$user, $section, , $patient] = $this->clinic(); + $service = $this->service($section); + + $protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [ + 'service_uuid' => $service->getUuid(), + 'session_count' => 4, + 'min_days' => 7, + 'ideal_days' => 14, + 'max_days' => 21, + ])['data']; + + $this->authJson('POST', '/api/v1/treatment-course', $user, [ + 'patient_uuid' => $patient->getUuid(), + 'protocol_uuid' => $protocol['uuid'], + ]); + self::assertSame(201, $this->responseCode()); + + $events = $this->repo()->search(DomainEvents::COURSE_STARTED, null, null, 10); + + self::assertNotEmpty($events); + self::assertArrayHasKey('course_uuid', $events[0]->getPayload()); + } + + // ── دسترسی ────────────────────────────────────────────────────────────── + + public function testOnlyAdminsCanReadTheEventLog(): void + { + [$user] = $this->clinic(); + + $this->authJson('GET', '/api/v1/domain-events', $user); + self::assertSame(403, $this->responseCode()); + + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + + $this->authJson('GET', '/api/v1/domain-events', $admin); + self::assertSame(200, $this->responseCode()); + } +} diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php new file mode 100644 index 00000000..d87108e4 --- /dev/null +++ b/tests/Report/ReportTest.php @@ -0,0 +1,280 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک گزارش'); + $this->em->persist($clinic); + $this->em->flush(); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبهٔ مرکزی'); + $this->em->persist($address); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر گزارش'); + $this->em->persist($doctor); + $this->em->flush(); + + return [$user, $section, $address, $doctor]; + } + + private function service(ServiceSection $section, string $name, int $solo): ServiceItem + { + $item = new ServiceItem($section, $name); + $item->setSoloDurationMinutes($solo); + $item->setPriceRials(1_000_000); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + /** نوبت انجام‌شده با مدت پیش‌بینی و مدت واقعی مشخص. */ + private function completed( + Doctor $doctor, + User $patient, + ServiceItem $service, + int $clinicId, + int $plannedMinutes, + int $actualMinutes, + int $daysAgo, + ): Appointment { + $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); + $start = time() - $daysAgo * 86400 + (++$this->slotCursor) * 60; + + $appointment = new Appointment( + $em->getRepository(Doctor::class)->find($doctor->getId()), + $em->getRepository(User::class)->find($patient->getId()), + $start, + $start + $actualMinutes * 60, + ); + $appointment->assignTenantPair('clinic', $clinicId); + $appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId())); + $appointment->setPatientName('بیمار گزارش'); + $appointment->setServiceDuration($plannedMinutes, 0); + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $appointment->transitionTo(Appointment::STATUS_COMPLETED); + + $em->persist($appointment); + $em->flush(); + + return $appointment; + } + + // ── دقت برنامه ────────────────────────────────────────────────────────── + + /** ⭐ سرویسی که ۶۰ دقیقه پیش‌بینی شده ولی ۹۰ دقیقه طول می‌کشد. */ + public function testAServiceThatRunsLongIsFlaggedHigh(): void + { + [$user, $section, $address, $doctor] = $this->clinic(); + $service = $this->service($section, 'لیزر فول‌بادی', 60); + $patient = $this->createUser(['ROLE_USER']); + + for ($i = 1; $i <= 4; $i++) { + $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i); + } + + $body = $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), + $user, + ); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + $row = $body['data']['rows'][0]; + + self::assertSame($service->getUuid(), $row['service_uuid']); + self::assertSame(60, $row['planned_minutes']); + self::assertSame(90, $row['actual_minutes']); + self::assertSame(50, $row['deviation_percent']); + self::assertSame('high', $row['severity']); + } + + /** انحراف منفی هم غلط است: ظرفیتی که می‌شد فروخت، خالی مانده. */ + public function testAServiceThatRunsShortIsAlsoFlagged(): void + { + [$user, $section, $address, $doctor] = $this->clinic(); + $service = $this->service($section, 'مشاوره', 60); + $patient = $this->createUser(['ROLE_USER']); + + for ($i = 1; $i <= 3; $i++) { + $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 30, $i); + } + + $rows = $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), + $user, + )['data']['rows']; + + self::assertSame(-50, $rows[0]['deviation_percent']); + self::assertSame('high', $rows[0]['severity']); + } + + /** زیر سه نمونه، میانگین معنا ندارد. */ + public function testASmallSampleIsNotReported(): void + { + [$user, $section, $address, $doctor] = $this->clinic(); + $service = $this->service($section, 'خدمت کم‌تکرار', 60); + $patient = $this->createUser(['ROLE_USER']); + + $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 1); + $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 2); + + $rows = $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), + $user, + )['data']['rows']; + + self::assertSame([], array_values(array_filter( + $rows, + static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), + ))); + } + + public function testAnAccurateServiceHasNoSeverity(): void + { + [$user, $section, $address, $doctor] = $this->clinic(); + $service = $this->service($section, 'خدمت دقیق', 60); + $patient = $this->createUser(['ROLE_USER']); + + for ($i = 1; $i <= 3; $i++) { + $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 60, $i); + } + + $rows = $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), + $user, + )['data']['rows']; + + $row = current(array_filter($rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid())); + + self::assertSame(0, $row['deviation_percent']); + self::assertSame('none', $row['severity']); + } + + // ── بهره‌وری منابع ────────────────────────────────────────────────────── + + /** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */ + public function testAResourceWithoutACalendarHasNullUtilization(): void + { + [$user, , $address] = $this->clinic(); + + $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ + 'address_uuid' => $address->getUuid(), + 'code' => 'device', + 'name' => 'دستگاه', + ]); + self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); + + $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type['data']['uuid'], + 'name' => 'لیزر ۱', + ]); + self::assertSame(201, $this->responseCode()); + + $body = $this->authJson( + 'GET', + sprintf( + '/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d', + $address->getUuid(), + time() - 7 * 86400, + time(), + ), + $user, + ); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + $row = $body['data']['rows'][0]; + + self::assertSame('لیزر ۱', $row['resource_name']); + self::assertSame(0, $row['available_minutes']); + self::assertNull($row['utilization'], 'تقسیم بر صفر معنای متفاوتی دارد'); + self::assertNull($row['active_ratio']); + self::assertFalse($row['wasted_capacity']); + } + + // ── محدودیت بازه و دسترسی ─────────────────────────────────────────────── + + public function testARangeLongerThanNinetyDaysIsRejected(): void + { + [$user] = $this->clinic(); + + $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 200 * 86400, time()), + $user, + ); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnInvertedRangeIsRejected(): void + { + [$user] = $this->clinic(); + + $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time(), time() - 86400), + $user, + ); + + self::assertSame(422, $this->responseCode()); + } + + public function testAnotherClinicSeesItsOwnNumbersOnly(): void + { + [$owner, $section, $address, $doctor] = $this->clinic(); + [$other] = $this->clinic(); + + $service = $this->service($section, 'لیزر', 60); + $patient = $this->createUser(['ROLE_USER']); + + for ($i = 1; $i <= 3; $i++) { + $this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i); + } + + $rows = $this->authJson( + 'GET', + sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()), + $other, + )['data']['rows']; + + self::assertSame([], array_values(array_filter( + $rows, + static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(), + ))); + } +}