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:
hamed
2026-07-31 12:27:54 +03:30
co-authored by Claude Opus 5
parent a379111606
commit 3c43955800
32 changed files with 2313 additions and 73 deletions
+4
View File
@@ -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() {
<Route path="treatment-course/:courseUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><TreatmentCoursePage /></RoleRoute>} />
<Route path="cancellation-policy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><CancellationPolicyPage /></RoleRoute>} />
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
<Route path="reports/resource-utilization" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceUtilizationPage /></RoleRoute>} />
<Route path="reports/plan-accuracy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PlanAccuracyPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
@@ -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'] },
+33
View File
@@ -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<ApiResponse<ReportEnvelope<UtilizationRow>>>(
`/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<ApiResponse<ReportEnvelope<AccuracyRow>>>(
`/api/v1/reports/plan-accuracy?from=${from}&to=${to}`,
),
});
return { rows: query.data?.data?.rows ?? [], loading: query.isLoading };
}
@@ -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<typeof vi.fn>;
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(<PlanAccuracyPage />, { 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(<PlanAccuracyPage />, { route: '/admin/reports/plan-accuracy' });
await waitFor(() =>
expect(screen.getByText(/کمتر از سه نوبت انجام‌شده در گزارش نمی‌آیند/)).toBeInTheDocument(),
);
});
});
+123
View File
@@ -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<AccuracyRow['severity'], { label: string; className: string }> = {
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<AccuracyRow>[] = [
{
key: 'service_name',
header: 'خدمت',
render: (r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{r.service_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.sample_size} نوبت</span>
</div>
),
},
{
key: 'planned_minutes',
header: 'پیش‌بینی',
render: (r) => <span style={{ fontSize: 13 }}>{r.planned_minutes} دقیقه</span>,
},
{
key: 'actual_minutes',
header: 'واقعی',
render: (r) => <span style={{ fontSize: 13 }}>{r.actual_minutes} دقیقه</span>,
},
{
key: 'deviation_percent',
header: 'انحراف',
render: (r) => (
<span
style={{
fontSize: 13,
fontWeight: 600,
color: r.severity === 'high' ? 'var(--danger)' : r.severity === 'medium' ? 'var(--warning)' : undefined,
}}
>
{r.deviation_percent > 0 ? `+${r.deviation_percent}` : r.deviation_percent}٪
</span>
),
},
{
key: 'fix',
header: '',
// گزارشی که راه اصلاح ندهد خوانده نمی‌شود.
render: (r) => (
<Link className="btn secondary sm" to={`/admin/services?service=${r.service_uuid}`}>
ویرایش بخشهای این خدمت
</Link>
),
},
{
key: 'severity',
header: 'شدت',
render: (r) => (
<span className={SEVERITY[r.severity].className}>
<span className="bdot" />
{SEVERITY[r.severity].label}
</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title="دقت برنامهٔ نوبت"
description="مقایسهٔ مدت پیش‌بینی‌شدهٔ هر خدمت با مدت واقعی نوبت‌های انجام‌شده."
backTo="/admin/settings-menu"
/>
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 200, margin: 0 }}>
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '30'))} options={RANGES} />
</div>
<span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'flex-end' }}>
خدماتی با کمتر از سه نوبت انجامشده در گزارش نمیآیند.
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage="داده‌ای برای این بازه نیست"
/>
</div>
</div>
);
}
@@ -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<UtilizationRow>[] = [
{
key: 'resource_name',
header: 'منبع',
render: (r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{r.resource_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.role}</span>
</div>
),
},
{
key: 'available_minutes',
header: 'در دسترس',
render: (r) => <span style={{ fontSize: 13 }}>{r.available_minutes} دقیقه</span>,
},
{
key: 'occupied_minutes',
header: 'اشغال',
render: (r) => <span style={{ fontSize: 13 }}>{r.occupied_minutes} دقیقه</span>,
},
{
key: 'active_minutes',
header: 'کار مفید',
render: (r) => <span style={{ fontSize: 13 }}>{r.active_minutes} دقیقه</span>,
},
{
key: 'utilization',
header: 'بهره‌وری',
render: (r) => (
<span
style={{ fontSize: 13 }}
title={r.utilization === null ? 'برای این منبع تقویمی تعریف نشده است' : undefined}
>
{percent(r.utilization)}
</span>
),
},
{
key: 'active_ratio',
header: 'نسبت کار مفید',
// توضیح باید در خودِ صفحه باشد، نه فقط در مستندات: کسی که گزارش را می‌خواند
// مستندات را باز نمی‌کند.
render: (r) => (
<span
style={{ fontSize: 13, fontWeight: 600, color: r.wasted_capacity ? 'var(--danger)' : undefined }}
title="چه سهمی از زمانِ اشغال، واقعاً کار روی بیمار بوده"
>
{percent(r.active_ratio)}
</span>
),
},
{
key: 'wasted_capacity',
header: '',
render: (r) =>
r.wasted_capacity ? (
<span className="badge red"><span className="bdot" />ظرفیت هدررفته</span>
) : null,
},
];
return (
<div className="fade-in">
<PageHeader
title="بهره‌وری منابع"
description="فاصلهٔ «اشغال» و «کار مفید» نشان می‌دهد بخش‌های نوبت درست تعریف شده‌اند یا نه."
backTo="/admin/settings-menu"
/>
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 220, margin: 0 }}>
<label>شعبه</label>
<SearchableSelect
value={branchUuid}
onChange={(v) => setBranchUuid(String(v ?? ''))}
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
placeholder="انتخاب شعبه"
/>
</div>
<div className="field" style={{ minWidth: 180, margin: 0 }}>
<label>بازه</label>
<SearchableSelect value={days} onChange={(v) => setDays(String(v ?? '7'))} options={RANGES} />
</div>
</div>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
«اشغال» شامل آمادهسازی، تمیزکاری و بخشهای انتظار است؛ «کار مفید» فقط زمانی که
بیمار حاضر بوده. فاصلهٔ این دو نشان میدهد بخشهای نوبت درست تعریف شدهاند یا نه.
</p>
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={rows}
loading={loading}
emptyMessage={branchUuid === '' ? 'برای دیدن گزارش، شعبه را انتخاب کنید' : 'منبعی برای این شعبه نیست'}
/>
</div>
</div>
);
}
+31
View File
@@ -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<T> {
from: number;
to: number;
rows: T[];
}
+147
View File
@@ -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 # ۱۶ تست
```
+106
View File
@@ -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` همان شناسه‌ای است که با آن تکراری را می‌شناسد.
تکرار مسئلهٔ مصرف‌کننده است، نه رویداد: تضمین «دقیقاً یک بار» در صف توزیع‌شده وجود ندارد.
@@ -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 (۴.۱/۴.۸) · تست کوئری‌شماری (۵.۶) |
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260731084058 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -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);
}
@@ -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;
}
@@ -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);
@@ -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();
+10
View File
@@ -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;
@@ -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;
}
@@ -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;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Report\Controller;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\Report\Service\PlanAccuracyReporter;
use App\Report\Service\ResourceUtilizationReporter;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Event\Entity\DomainEventLog;
use App\Shared\Event\Repository\DomainEventLogRepository;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Report')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ReportController extends BaseController
{
/** بازهٔ بزرگ‌تر از این، هم کند است هم عملاً خوانده نمی‌شود. */
private const MAX_RANGE_DAYS = 90;
public function __construct(
private readonly ResourceUtilizationReporter $utilization,
private readonly PlanAccuracyReporter $accuracy,
private readonly ClinicResourceRepository $resources,
private readonly DomainEventLogRepository $events,
private readonly BranchResolver $branches,
) {}
#[Route('/api/v1/reports/resource-utilization', name: 'report_resource_utilization', methods: ['GET'])]
public function resourceUtilization(#[CurrentUser] User $user, Request $request): JsonResponse
{
$branch = $request->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',
);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Report\Service;
use App\Appointment\Entity\Appointment;
use Doctrine\ORM\EntityManagerInterface;
/**
* مدت پیش‌بینی‌شده در برابر مدت واقعی — تشخیص تعریف غلط بخش‌ها.
*
* بند ۱۷ مستند، ریسک سوم: «کلینیک بخش‌های نوبت را اشتباه تعریف کند → ظرفیت غلط حساب
* می‌شود». سرویسی که یک ساعت پیش‌بینی شده ولی یک‌ساعت‌ونیم طول می‌کشد، هر روز نیم ساعت
* از ظرفیت کلینیک را بی‌صدا می‌خورد و هیچ خطایی هم نمی‌دهد.
*
* مبنای «واقعی» فاصلهٔ ثبت‌شدهٔ اسلات است، نه ساعت ورود و خروج بیمار — چون آن دومی
* جایی ثبت نمی‌شود و حدس زدنش بدتر از نداشتنش است.
*/
final class PlanAccuracyReporter
{
/** زیر این تعداد نمونه، میانگین معنا ندارد. */
public const MIN_SAMPLE = 3;
public function __construct(
private readonly EntityManagerInterface $em,
) {}
/**
* @return list<array<string, mixed>> مرتب بر اساس شدت انحراف
*/
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',
};
}
}
@@ -0,0 +1,183 @@
<?php
namespace App\Report\Service;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Service\ResourceAvailabilityService;
use Doctrine\ORM\EntityManagerInterface;
/**
* بهره‌وری منابع — تنها ابزاری که به کلینیک می‌گوید تعریف بخش‌هایش درست است یا نه.
*
* سه عدد، سه معنای متفاوت:
*
* | عدد | یعنی |
* |---|---|
* | `available_minutes` | منبع طبق تقویمش چقدر در دسترس بوده |
* | `occupied_minutes` | چقدر **گرفته** شده — شامل آماده‌سازی، تمیزکاری و بخش‌های انتظار |
* | `active_minutes` | چقدر واقعاً کار انجام شده — فقط بخش‌هایی که بیمار حاضر بوده |
*
* فاصلهٔ `occupied` و `active` همان چیزی است که تعریف غلط بخش‌ها را لو می‌دهد: منبعی که
* هشت ساعت اشغال بوده ولی دو ساعت کار کرده، یا بخش‌های `passive` زیادی گرفته یا
* زمان‌های انتظارش اشتباه به او نسبت داده شده.
*/
final class ResourceUtilizationReporter
{
/** زیر این نسبت، ظرفیت عملاً هدر می‌رود. */
public const WASTE_THRESHOLD = 0.3;
public function __construct(
private readonly ResourceAvailabilityService $calendars,
private readonly EntityManagerInterface $em,
) {}
/**
* @param ClinicResource[] $resources
* @return list<array<string, mixed>>
*/
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<string, mixed> */
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<int, int>
*/
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<int, int>
*/
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;
}
}
@@ -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();
}
/**
* پرس‌وجوی داغِ تسک ۰۶: «منابع فعالِ این شعبه از این نوع که **همهٔ** این مهارت‌ها
* را دارند».
@@ -0,0 +1,65 @@
<?php
namespace App\Shared\Event\Command;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* پاکسازی رویدادهای **منتشرشدهٔ** قدیمی.
*
* ردیف منتشرنشده هرگز حذف نمی‌شود، حتی اگر سالخورده باشد: آن یک رویداد گم‌شده است و
* حذفش یعنی پاک کردن مدرکِ همان گم‌شدن.
*/
#[AsCommand(name: 'app:events:prune', description: 'Delete published domain events older than a retention window.')]
class PruneDomainEventsCommand extends Command
{
public function __construct(private readonly Connection $connection)
{
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Shared\Event\Command;
use App\Shared\Event\Entity\DomainEventLog;
use App\Shared\Event\Repository\DomainEventLogRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* انتشار صندوق خروجی: ردیف‌های `published_at IS NULL` به messenger می‌روند.
*
* شکست انتشار ردیف را نمی‌کشد؛ `attempts` بالا می‌رود و خطا ثبت می‌شود. بعد از سقف
* تلاش، ردیف با خطایش باقی می‌ماند تا ادمین ببیند — حذف خاموش یعنی رویداد گم‌شدهٔ بی‌رد.
*/
#[AsCommand(name: 'app:events:publish', description: 'Publish pending domain events from the outbox.')]
class PublishDomainEventsCommand extends Command
{
public function __construct(
private readonly DomainEventLogRepository $events,
private readonly MessageBusInterface $bus,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this->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);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Shared\Event;
use App\Shared\Event\Entity\DomainEventLog;
use Doctrine\ORM\EntityManagerInterface;
/**
* تنها نقطهٔ ثبت رویداد دامنه.
*
* `record()` عمداً **flush نمی‌کند**: ردیف رویداد باید در همان تراکنشی commit شود که
* خودِ تغییر را انجام می‌دهد. اگر اینجا flush می‌کردیم، rollbackِ تراکنش اصلی رویدادی
* را جا می‌گذاشت که هرگز اتفاق نیفتاده.
*/
final class DomainEventPublisher
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
/**
* @param array<string, mixed> $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<string, mixed> $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;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Shared\Event;
/**
* فهرست بستهٔ نام رویدادها — بند ۱۶ مستند.
*
* نام رویداد قرارداد عمومی است: مصرف‌کننده روی رشته شرط می‌گذارد. تایپوی یک حرفی
* یعنی رویدادی که هیچ‌کس نمی‌شنود و هیچ خطایی هم نمی‌دهد، پس فهرست بسته است.
*/
final class DomainEvents
{
public const HOLD_CREATED = 'HoldCreated';
public const APPOINTMENT_BOOKED = 'AppointmentBooked';
public const APPOINTMENT_CANCELLED = 'AppointmentCancelled';
public const APPOINTMENT_RESCHEDULED = 'AppointmentRescheduled';
public const PATIENT_NO_SHOW = 'PatientNoShow';
public const APPOINTMENT_COMPLETED = 'AppointmentCompleted';
public const RESOURCE_BLOCKED = 'ResourceBlocked';
public const RESOURCE_RELEASED = 'ResourceReleased';
public const COURSE_STARTED = 'CourseStarted';
public const COURSE_SESSION_COMPLETED = 'CourseSessionCompleted';
public const COURSE_COMPLETED = 'CourseCompleted';
public const PACKAGE_PURCHASED = 'PackagePurchased';
public const CREDIT_CONSUMED = 'CreditConsumed';
public const CREDIT_REFUNDED = 'CreditRefunded';
public const ALL = [
self::HOLD_CREATED,
self::APPOINTMENT_BOOKED,
self::APPOINTMENT_CANCELLED,
self::APPOINTMENT_RESCHEDULED,
self::PATIENT_NO_SHOW,
self::APPOINTMENT_COMPLETED,
self::RESOURCE_BLOCKED,
self::RESOURCE_RELEASED,
self::COURSE_STARTED,
self::COURSE_SESSION_COMPLETED,
self::COURSE_COMPLETED,
self::PACKAGE_PURCHASED,
self::CREDIT_CONSUMED,
self::CREDIT_REFUNDED,
];
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Shared\Event\Entity;
use App\Shared\Event\Repository\DomainEventLogRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* صندوق خروجی رویدادهای دامنه (outbox).
*
* ردیف رویداد **در همان تراکنشی** نوشته می‌شود که خودِ تغییر را انجام می‌دهد، و یک
* worker بعداً منتشرش می‌کند. بدون این الگو دو حالت شکست ممکن است:
*
* | حالت | نتیجه |
* |---|---|
* | انتشار پیش از commit، بعد rollback | پیامک رفته، نوبتی وجود ندارد |
* | commit موفق، انتشار شکست خورد | نوبت هست، هیچ‌کس مطلع نشد |
*
* با outbox حداکثر **تأخیر** داریم، هرگز گم‌شدن.
*
* این جدول با `AppointmentEvent` موجود اشتباه نشود: آن تاریخچهٔ وضعیت یک نوبت است،
* این اعلان تغییر به بیرونِ دامنه.
*/
#[ORM\Entity(repositoryClass: DomainEventLogRepository::class)]
#[ORM\Table(name: 'domain_events')]
#[ORM\Index(columns: ['published_at', 'occurred_at'], name: 'idx_de_pending')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'occurred_at'], name: 'idx_de_tenant')]
#[ORM\Index(columns: ['name', 'occurred_at'], name: 'idx_de_name')]
class DomainEventLog
{
use TenantOwnedTrait;
/** سقف تلاش — ردیف مرده با خطایش می‌ماند تا دیده شود، حذف نمی‌شود. */
public const MAX_ATTEMPTS = 5;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'bigint')]
private ?string $id = null;
/** شناسهٔ idempotency برای مصرف‌کننده. */
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 60)]
private string $name;
/** @var array<string, scalar|null> */
#[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<string, mixed> $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<string, mixed> $payload
* @return array<string, scalar|null>
*/
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<string, mixed> */
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,
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Shared\Event\Message;
/**
* پیام async یک رویداد دامنه.
*
* `uuid` شناسهٔ idempotency است: messenger ممکن است پیام را دوباره تحویل بدهد، و
* **مصرف‌کننده** باید تکراری را تشخیص بدهد — نه اینکه رویداد تضمین یکتایی بدهد.
*/
final readonly class DomainEventMessage
{
/** @param array<string, scalar|null> $payload */
public function __construct(
public string $uuid,
public string $name,
public string $entityType,
public int $entityId,
public array $payload,
public int $occurredAt,
) {}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Shared\Event\MessageHandler;
use App\Shared\Event\Message\DomainEventMessage;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* درزِ اتصال مصرف‌کننده‌ها.
*
* خودش کاری جز ثبت لاگ نمی‌کند و **نباید بکند**: پیامک، حسابداری و گزارش هر کدام
* مصرف‌کنندهٔ خودشان را کنار این ثبت می‌کنند. وجودش لازم است چون messenger پیامِ
* بدون handler را خطا می‌دهد، و آن خطا در صندوق خروجی به‌عنوان «شکست انتشار» ثبت
* می‌شد — یعنی یک ایراد پیکربندی، شبیه یک رویداد گم‌شده به نظر می‌رسید.
*
* مصرف‌کنندهٔ تازه باید **idempotent** باشد: messenger ممکن است پیام را دوباره تحویل
* بدهد و `DomainEventMessage::$uuid` همان شناسه‌ای است که با آن تکراری را می‌شناسد.
*/
#[AsMessageHandler]
final class DomainEventHandler
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function __invoke(DomainEventMessage $message): void
{
$this->logger->info('domain event published', [
'uuid' => $message->uuid,
'name' => $message->name,
'entity_type' => $message->entityType,
'entity_id' => $message->entityId,
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Shared\Event\Repository;
use App\Shared\Event\Entity\DomainEventLog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<DomainEventLog> */
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();
}
}
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace App\Tests\Report;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Shared\Event\DomainEventPublisher;
use App\Shared\Event\DomainEvents;
use App\Shared\Event\Entity\DomainEventLog;
use App\Shared\Event\Repository\DomainEventLogRepository;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* رویدادهای دامنه و صندوق خروجی — تسک ۱۴.
*
* دو تضمین که کل الگو برایشان وجود دارد: رویداد **بعد از** commit منتشر می‌شود، و
* هیچ رویدادی گم نمی‌شود.
*/
class DomainEventTest extends ApiTestCase
{
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: PatientRecord} */
private function clinic(): array
{
$user = $this->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());
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
namespace App\Tests\Report;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* گزارش بهره‌وری منابع و دقت برنامه — تسک ۱۴.
*
* گزارش دقت برنامه تنها بازخوردی است که به کلینیک می‌گوید تعریف بخش‌هایش درست است یا
* نه؛ بدون آن، ابزار قدرتمند تسک ۰۵ کور کار می‌کند.
*/
class ReportTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
private function clinic(): array
{
$user = $this->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(),
)));
}
}