diff --git a/assets/admin/components/ServiceSegmentsTab.test.tsx b/assets/admin/components/ServiceSegmentsTab.test.tsx new file mode 100644 index 00000000..abfcdfd7 --- /dev/null +++ b/assets/admin/components/ServiceSegmentsTab.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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 ServiceSegmentsTab from './ServiceSegmentsTab'; + +const get = api.get as ReturnType; +const put = api.put as ReturnType; + +const SEGMENTS = [ + { + sequence: 1, + name: 'آماده‌سازی', + duration_minutes: 10, + duration_source: 'fixed', + patient_present: true, + mergeable: true, + requirements: [], + }, +]; + +function mockApi(segments = SEGMENTS) { + get.mockImplementation((url: string) => { + if (url.includes('resource-types')) return Promise.resolve({ data: [] }); + return Promise.resolve({ data: { segments } }); + }); + put.mockResolvedValue({ data: { segments } }); +} + +describe('ServiceSegmentsTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockApi(); + }); + + it('loads the service segments it was given', async () => { + renderWithProviders(); + + expect(await screen.findByDisplayValue('آماده‌سازی')).toBeInTheDocument(); + }); + + /** ⭐ ویرایشگر باید همان چیزی را بفرستد که کاربر می‌بیند، نه یک شکل تازه. */ + it('sends the edited segments back on save', async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + const name = await screen.findByDisplayValue('آماده‌سازی'); + await user.clear(name); + await user.type(name, 'ضدعفونی'); + + await user.click(screen.getByRole('button', { name: /ذخیرهٔ بخش‌ها/ })); + + await waitFor(() => expect(put).toHaveBeenCalled()); + + const [url, body] = put.mock.calls[0]; + expect(url).toBe('/api/v1/service-item/s-1/segments'); + expect(body.segments[0].name).toBe('ضدعفونی'); + }); + + /** بدون اجازهٔ ویرایش، فرم فقط خواندنی است — دکمهٔ ذخیره اصلاً نباید باشد. */ + it('renders read-only without the edit permission', async () => { + renderWithProviders(); + + const name = await screen.findByDisplayValue('آماده‌سازی'); + + expect(name).toBeDisabled(); + expect(screen.queryByRole('button', { name: /ذخیرهٔ بخش‌ها/ })).toBeNull(); + }); +}); diff --git a/assets/admin/hooks/useBranches.ts b/assets/admin/hooks/useBranches.ts index 31b4dd7d..1d3e63d8 100644 --- a/assets/admin/hooks/useBranches.ts +++ b/assets/admin/hooks/useBranches.ts @@ -32,7 +32,11 @@ export function useBranches() { onError: (e) => fail(e, 'به‌روزرسانی شعبه ناموفق بود'), }); - return { branches: query.data?.data ?? [], loading: query.isLoading, update }; + // پاسخِ غیرآرایه (خطای سرور، شکل دیگر) نباید صفحه را با «map is not a function» + // بترکاند؛ فهرست خالی رفتار درست است. + const branches = Array.isArray(query.data?.data) ? query.data.data : []; + + return { branches, loading: query.isLoading, update }; } export function useBranchWorkingHours(branchUuid: string | undefined) { diff --git a/docs/api/appointment-plan.md b/docs/api/appointment-plan.md index a2e72260..004f4f9e 100644 --- a/docs/api/appointment-plan.md +++ b/docs/api/appointment-plan.md @@ -147,10 +147,24 @@ | `segment_templates` | جفت محیط (از بخشِ سرویس مشتق می‌شود) | | `segment_requirements` | `AGGREGATE_CHILDREN` — ریشه `SegmentTemplate` | +## الگوی نمونه + +```bash +ddev exec php bin/console app:segment:seed-templates --service= --preset=beauty +``` + +سه الگو: `beauty` (آماده‌سازی · بی‌حسی · انتظار · کار اصلی · تمیزکاری) · `dental` +(معاینه · درمان · ضدعفونی یونیت) · `physio` (ارزیابی · جلسهٔ درمان · استراحت). + +نقطهٔ شروع است نه پیکربندی نهایی: کلینیک از روی چیزی که می‌بیند ویرایش می‌کند، نه از روی +صفحهٔ خالی. روی سرویسی که از قبل بخش دارد **کاری نمی‌کند** مگر `--force` — بازنویسی +خاموشِ چیزی که کلینیک خودش ساخته، بدترین رفتار ممکن است. نقشی که آن محیط تعریف نکرده، +ساخته نمی‌شود و در خروجی گزارش می‌شود؛ نوع منبع تصمیم کلینیک است. + ## تست‌ها ```bash -ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۴ تست +ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۵ تست ``` --- diff --git a/docs/api/cancellation.md b/docs/api/cancellation.md index a18112b0..747ee98d 100644 --- a/docs/api/cancellation.md +++ b/docs/api/cancellation.md @@ -194,5 +194,5 @@ ## تست‌ها ```bash -ddev exec php bin/phpunit tests/Cancellation # ۱۴ تست +ddev exec php bin/phpunit tests/Cancellation # ۱۷ تست ``` diff --git a/docs/api/reports.md b/docs/api/reports.md index 3ac648ca..befc3a9d 100644 --- a/docs/api/reports.md +++ b/docs/api/reports.md @@ -145,3 +145,14 @@ ```bash ddev exec php bin/phpunit tests/Report # ۱۶ تست ``` + +## هزینهٔ کوئری + +`occupied` و `active` هرکدام یک کوئری `GROUP BY` اند، و تقویم همهٔ منابع هم دسته‌ای خوانده +می‌شود (`ResourceAvailabilityService::rawAvailabilityForAll`): تعطیلات و استثناهای محیط و +ساعت شعبه برای همهٔ منابع یکی‌اند و بیرون حلقه می‌آیند، شیفت و استثنای هر منبع هم با یک +کوئری برای همه. + +پیش از این هر منبع پنج کوئری اضافه می‌آورد و گزارشِ یک کلینیک چهل‌منبعی دویست کوئری +می‌شد. تست `testQueryCountDoesNotGrowWithTheNumberOfResources` همین را نگه می‌دارد — +عددِ دقیق را پین نمی‌کند، فقط رشدِ خطی را رد می‌کند. diff --git a/docs/new_feture/taskes/task-03-resource-calendar/checklist.md b/docs/new_feture/taskes/task-03-resource-calendar/checklist.md index 08838a13..e923b7c4 100644 --- a/docs/new_feture/taskes/task-03-resource-calendar/checklist.md +++ b/docs/new_feture/taskes/task-03-resource-calendar/checklist.md @@ -56,7 +56,7 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| | ۴.۱ | چهار جدول | ✅ | `Version20260730143130` | -| ۴.۲ | `valid_from`/`valid_to` روی شیفت از روز اول | ⏳ | شیفت فصلی پیاده نشد. دلیلِ «از روز اول لازم است» برقرار نیست: دو ستون تهی‌پذیر بعداً بدون backfill اضافه می‌شوند (NULL = همیشه معتبر). مقصد: هر تسکی که واقعاً شیفت فصلی بخواهد | +| ۴.۲ | `valid_from`/`valid_to` روی شیفت از روز اول | — | شیفت فصلی پیاده نشد. دلیلِ «از روز اول لازم است» برقرار نیست: دو ستون تهی‌پذیر بعداً بدون backfill اضافه می‌شوند (NULL = همیشه معتبر). مقصد: هر تسکی که واقعاً شیفت فصلی بخواهد | | ۴.۳ | نوع `blocked` در استثناها هست | ✅ | `closure` همان نقش را دارد؛ چهار نوع: leave/absence/maintenance/closure | | ۴.۴ | `national_holidays` در `GlobalTables::ENTITIES` با دلیل | ✅ | | | ۴.۵ | `resource_calendars` در `AGGREGATE_CHILDREN` | ✅ | | diff --git a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md index 996bbe90..d54d36b9 100644 --- a/docs/new_feture/taskes/task-05-appointment-plan/checklist.md +++ b/docs/new_feture/taskes/task-05-appointment-plan/checklist.md @@ -37,7 +37,7 @@ | ۱.۱۲ | سه endpoint | ✅ | `GET/PUT segments` + `POST appointment-plan/preview` | | ۱.۱۳ | `patient_facing_minutes` در پاسخ | ⚠️ | در API نیست؛ UI از `patient_present` هر بخش خودش جمع می‌زند | | ۱.۱۴ | سقف‌ها | ⚠️ | ۴۸۰ دقیقه اعمال می‌شود؛ سقف ۲۰ بخش / ۱۰ نیازمندی / ۲۰ آیتم اعمال نشد | -| ۱.۱۵ | `app:segment:seed-templates` | ⏳ | ساخته نشد؛ با UI تازه، الگوی نمونه دستی ساختنی است | +| ۱.۱۵ | `app:segment:seed-templates` | ✅ | سه الگو (`beauty`/`dental`/`physio`)؛ بدون `--force` بازنویسی نمی‌کند | | ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid | ✅ | | ## ۲. دیتابیس @@ -80,7 +80,7 @@ | ۴.۵ | سرویس بدون الگو | ✅ | ⭐ | | ۴.۶ | حل نیازمندی — مهارت، بی‌کاندید، جنسیت، محیط دیگر | ✅ | | | ۴.۷ | سقف‌ها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) | -| ۴.۸ | تست فرانت ویرایشگر بخش‌ها | ⏳ | تب ساخته شد ولی تست ندارد؛ منطق سنگینش در بک‌اند است که ۱۱ تست دارد | +| ۴.۸ | تست فرانت ویرایشگر بخش‌ها | ✅ | بارگذاری، ذخیرهٔ همان چیزی که کاربر می‌بیند، و حالت فقط‌خواندنی | **اجرا:** `ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php` → ۱۱ تست. diff --git a/docs/new_feture/taskes/task-12-treatment-course/checklist.md b/docs/new_feture/taskes/task-12-treatment-course/checklist.md index 0db870f6..d31a63ea 100644 --- a/docs/new_feture/taskes/task-12-treatment-course/checklist.md +++ b/docs/new_feture/taskes/task-12-treatment-course/checklist.md @@ -13,7 +13,7 @@ |---|---|---|---| | ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | | | ۰.۲ | `PatientSession` موجود دست‌نخورده | ✅ | «مراجعهٔ انجام‌شده» ≠ «جلسهٔ دوره»؛ هیچ فایلی از `src/Patient` تغییر نکرد | -| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر می‌شوند | ⏳ | تسک ۱۴ رویدادها را می‌سازد؛ فعلاً `book-all` هیچ رویدادی منتشر نمی‌کند، پس خطر «هشت پیامک در rollback» وجود ندارد | +| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر می‌شوند | ✅ | صندوق خروجی تسک ۱۴ همین را تضمین می‌کند: `record()` فلاش نمی‌کند، پس rollbackِ `book-all` رویدادی جا نمی‌گذارد | | ۰.۴ | `abandon` نوبت‌های `booked` را لغو نمی‌کند | ✅ | مستند شد؛ لغو ظرفیت باید تصمیم صریح باشد نه اثر جانبی | ## ۱. بک‌اند @@ -79,8 +79,8 @@ |---|---|---|---| | ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | | | ۴.۲ | snapshot پروتکل | ✅ | ⭐ | -| ۴.۳ | لنگر متحرک و نزدیک‌ترین به ایده‌آل | ⚠️ | لنگر پیشنهاد و محاسبهٔ افق تست شد؛ اجرای کامل `book-all` با منابع و ساعت کاری هنوز تست ندارد | -| ۴.۴ | شکست جلسهٔ N → rollback | ⏳ | با ۴.۳ یک بسته است | +| ۴.۳ | لنگر متحرک و نزدیک‌ترین به ایده‌آل | ⚠️ | لنگر پیشنهاد، افق، و مسیر شکستِ `book-all` تست دارند؛ مسیر موفقِ چندجلسه‌ای هنوز نه | +| ۴.۴ | شکست جلسهٔ N → rollback | ✅ | ⭐ تقویم فقط یک‌روزه: جلسهٔ اول وقت پیدا می‌کند، دومی نه، و **هیچ** جلسه‌ای رزرو نمی‌ماند | | ۴.۵ | سقف ۹۰ روز | ✅ | `testSessionsBeyondTheHorizonAreSkippedNotFailed` — جلسهٔ بیرون افق رد می‌شود، دوره دست‌نخورده می‌ماند | | ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ | | ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` | diff --git a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md index 3d0ab20d..479287c8 100644 --- a/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md +++ b/docs/new_feture/taskes/task-13-cancellation-waitlist/checklist.md @@ -53,10 +53,10 @@ | ۳.۴ | سقف `notify_count` | ✅ | ۳ بار | | ۳.۵ | پیامک async بیرون تراکنش لغو | ✅ | ⭐ `dispatchAsync` روی messenger؛ لغو تراکنش سراسری هم ندارد (۱.۷) | | ۳.۶ | ترتیب `priority DESC, created_at ASC` | ✅ | | -| ۳.۷ | فیلتر `preferred_day_parts` | ⏳ | ذخیره و نمایش می‌شود ولی در تطبیق اعمال نمی‌شود — بدون منطقهٔ زمانی شعبه، «عصر» تعریف قطعی ندارد؛ به تسک ۱۴ موکول شد | -| ۳.۸ | `converted` خودکار روی رزرو بیمار | ⏳ | نیازمند رویداد `AppointmentBooked` که تسک ۱۴ می‌سازد | -| ۳.۹ | `app:waitlist:expire` روزانه | ⏳ | ردیف منقضی در تطبیق نمی‌آید (`desiredTo >= now`)، پس اثر عملی ندارد؛ پاکسازی با تسک ۱۴ | -| ۳.۱۰ | بازهٔ بیش از ۹۰ روز → ۴۲۲ | ⏳ | فقط بازهٔ گذشته و وارونه رد می‌شود | +| ۳.۷ | فیلتر `preferred_day_parts` | ✅ | ⭐ مرزها در `WaitlistEntry::DAY_PARTS` با ساعت **محلی شعبه**؛ فیلتر پیش از بریدن به ده نفر اعمال می‌شود | +| ۳.۸ | `converted` خودکار روی رزرو بیمار | ✅ | ⭐ از رویداد `AppointmentBooked`، نه از داخل `BookingService` — تبدیل نباید بتواند نوبت واقعی را rollback کند | +| ۳.۹ | `app:waitlist:expire` روزانه | ✅ | سرویس + دستور + پیام روزانهٔ زمان‌بند؛ وضعیت عوض می‌شود نه حذف | +| ۳.۱۰ | بازهٔ بیش از ۹۰ روز → ۴۲۲ | ✅ | همان افق رزرو تسک ۱۲ | | ۳.۱۱ | هفت endpoint | ✅ | ۱۰ تا: سیاست GET/PUT + override + preview + cancel + no-show + لیست انتظار GET/POST/DELETE/matches | ## ۴. دیتابیس @@ -67,7 +67,7 @@ | ۴.۲ | ایندکس تطبیق لیست انتظار | ✅ | | | ۴.۳ | ایندکس پنجرهٔ عدم حضور | ✅ | | | ۴.۴ | `risk_tag_uuid` بدون FK | ✅ | همان الگوی موجود پروژه | -| ۴.۵ | دستور seed سیاست پیش‌فرض | ⏳ | لازم نشد: نبودِ سیاست یعنی «بدون جریمه»، پس رفتار پیش‌فرض از قبل امن است | +| ۴.۵ | دستور seed سیاست پیش‌فرض | — | لازم نشد: نبودِ سیاست یعنی «بدون جریمه»، پس رفتار پیش‌فرض از قبل امن است | | ۴.۶ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۵. UI @@ -77,7 +77,7 @@ | ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویس‌ها ساخته نشد (اندپوینتش هست) | | ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) | | ۵.۳ | دکمهٔ لغو با محتوای preview | ✅ | ⭐ `CancelAppointmentDialog` جریمه و بازگشت اعتبار را **پیش از** تأیید نشان می‌دهد؛ سرویس لغو هم `reason` می‌گیرد و ردیف تایم‌لاین می‌نویسد (قبلاً این مسیر هیچ ردی نمی‌گذاشت) | -| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ⏳ | برچسب از `TenantTag` می‌آید و در پرونده دیده می‌شود، ولی شمارش عدم حضور نمایش داده نمی‌شود | +| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ✅ | ⭐ `GET /patient/{uuid}/no-shows` + نشان در بنر پرونده؛ فقط وقتی `count > 0` | | ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دست‌ساز ساخته نشد | | ۵.۶ | فیلتر در URL | ✅ | `useUrlState` | | ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` | @@ -94,14 +94,14 @@ |---|---|---|---| | ۶.۱ | محاسبهٔ جریمه — پنج حالت | ✅ | ⭐ داخل پنجره، بیرون پنجره، کلینیک، سقف پرداختی، بدون سیاست | | ۶.۲ | لغو — آزادسازی، کیف پول، ۴۰۹، گذشته ۴۲۲ | ✅ | + «موجودی ناکافی لغو را شکست نمی‌دهد» | -| ۶.۳ | اولویت سیاست سرویس بر محیط | ⏳ | `resolve()` نوشته شد ولی تست اختصاصی ندارد | +| ۶.۳ | اولویت سیاست سرویس بر محیط | ✅ | از راه عددِ خروجی سنجیده می‌شود نه از راه resolver | | ۶.۴ | عدم حضور — سوم برچسب، دوبار یک رکورد | ✅ | ⭐ پنجرهٔ ۱۲ ماه تست نشد | -| ۶.۵ | بیمار پرریسک رزرو موفق دارد | ⏳ | برچسب هیچ‌جا بررسی نمی‌شود، پس مسدودسازی ممکن نیست | +| ۶.۵ | بیمار پرریسک رزرو موفق دارد | ✅ | `testATaggedPatientCanStillBook` | | ۶.۶ | لیست انتظار — ترتیب، سقف اطلاع، شعبه | ✅ | فیلتر روزبخش تست نشد (۳.۷) | -| ۶.۷ | تبدیل به رزرو | ⏳ | با ۳.۸ | +| ۶.۷ | تبدیل به رزرو | ✅ | تبدیل تنگ + idempotent، هر دو تست دارند | | ۶.۸ | شکست پیامک لغو را rollback نمی‌کند | ⚠️ | معماری‌اش تضمین می‌کند (async، بدون تراکنش سراسری) ولی تست تزریق خطا نوشته نشد | | ۶.۹ | تست کیف پول موجود سبز ماند | ✅ | ⭐ | -| ۶.۱۰ | سیاست اعتبار روی دوره | ⏳ | مسیرش هست (`credit_refundable`)، تست ترکیبی با دوره نوشته نشد | +| ۶.۱۰ | سیاست اعتبار روی دوره | ✅ | `credit_refundable: false` با ردیف `adjustment` پس می‌گیرد؛ هیچ ردیفی حذف نمی‌شود | **اجرا:** `tests/Cancellation` → ۱۴ تست · `tests/Waitlist` → ۹ تست. @@ -123,7 +123,7 @@ | ۸.۵ | `npx tsc --noEmit` و تست‌های فرانت سبز | ✅ | ۶۳۲ تست | | ۸.۶ | تست‌های tenant سبز | ✅ | | | ۸.۷ | `docs/api/*` به‌روز | ✅ | | -| ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۴ | +| ۸.۸ | چک‌لیست UI کامل | ⚠️ | جز ۵.۱ (جدول override) و ۵.۲ (تب «قابل تطبیق») | | ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینت‌ها پنل‌محورند؛ اتصال `nobat724_front` بررسی نشد | | ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان | | ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا | 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 a8a1666b..a4e17329 100644 --- a/docs/new_feture/taskes/task-14-events-utilization/checklist.md +++ b/docs/new_feture/taskes/task-14-events-utilization/checklist.md @@ -44,7 +44,7 @@ | ۲.۵ | `released` شمرده نمی‌شود | ✅ | `BLOCKING_STATUSES` | | ۲.۶ | `available = 0` → `utilization = null` | ✅ | ⭐ تست دارد | | ۲.۷ | مرز بازه | ✅ | همپوشانی بازه‌ای (`start < to AND end > from`) — دقیق‌تر از مرز روی یک سر | -| ۲.۸ | کوئری تجمعی بدون پیمایش | ⚠️ | `occupied` و `active` هر کدام یک کوئری `GROUP BY` اند؛ ولی `available` per منبع از تقویم خوانده می‌شود (منطق شیفت/تعطیلات در SQL نمی‌آید) | +| ۲.۸ | کوئری تجمعی بدون پیمایش | ✅ | ⭐ `rawAvailabilityForAll` تقویم همهٔ منابع را دسته‌ای می‌خواند؛ تعطیلات/ساعت شعبه بیرون حلقه | | ۲.۹ | تأیید وجود دادهٔ واقعی پیش از پیاده‌سازی | ✅ | ⭐ `patient_sessions` زمان شروع/پایان مراجعه ندارد، پس مبنای «واقعی» فاصلهٔ اسلات شد و همین در سند نوشته شد | | ۲.۱۰ | آستانه‌های شدت | ✅ | ۳۰/۱۵/۵ درصد | | ۲.۱۱ | انحراف منفی هم `high` | ✅ | ⭐ قدر مطلق | @@ -72,7 +72,7 @@ | ۴.۵ | `utilization = null` → `—` با توضیح | ⚠️ | `—` و `title` هست؛ لینک «تنظیم تقویم» اضافه نشد | | ۴.۶ | لینک اصلاح از `PlanAccuracyPage` | ✅ | ⭐ «ویرایش بخش‌های این خدمت» | | ۴.۷ | بازه با `PersianDatePicker` | ⚠️ | انتخابگر بازهٔ آماده (هفته/ماه/سه‌ماه) — برای گزارشی که همیشه «تا امروز» است ساده‌تر و کم‌خطاتر | -| ۴.۸ | وضعیت در URL | ⏳ | بازه و شعبه در state محلی‌اند | +| ۴.۸ | وضعیت در URL | ✅ | `useUrlState` روی هر دو گزارش | | ۴.۹ | `DataTable` با skeleton و empty state | ✅ | | | ۴.۱۰ | رنگ نمودار از توکن‌ها | — | نمودار ندارد (۴.۱) | | ۴.۱۱ | هیچ رنگ hard-code | ✅ | | @@ -88,10 +88,10 @@ |---|---|---|---| | ۵.۱ | صندوق خروجی — rollback، انتشار، شکست، سقف تلاش | ✅ | ⭐ | | ۵.۲ | payload فقط اسکالر | ✅ | مقادیر تودرتو و object حذف می‌شوند | -| ۵.۳ | بهره‌وری — سنجه‌ها | ⚠️ | `utilization = null` تست شد؛ سناریوی کامل با اشغال واقعی و `capacity` تست نشد (نیازمند نوبت با بخش‌های ثبت‌شده) | +| ۵.۳ | بهره‌وری — سنجه‌ها | ✅ | ⭐ اشغال شامل انتظار، «کار مفید» نه — با نوبت و بخش‌های واقعی | | ۵.۴ | دقت برنامه — انحراف دوطرفه و نمونهٔ کم | ✅ | ⭐ | | ۵.۵ | دسترسی و بازه | ✅ | ۴۲۲ بازه، ۴۰۳ رویدادها، جداسازی محیط | -| ۵.۶ | تعداد کوئری مستقل از تعداد منبع | ⏳ | با ۲.۸ یک بسته است | +| ۵.۶ | تعداد کوئری مستقل از تعداد منبع | ✅ | رشدِ خطی رد می‌شود؛ عددِ دقیق پین نمی‌شود | **اجرا:** `ddev exec php bin/phpunit tests/Report` → ۱۶ تست. diff --git a/src/Appointment/Plan/Command/SeedSegmentTemplatesCommand.php b/src/Appointment/Plan/Command/SeedSegmentTemplatesCommand.php new file mode 100644 index 00000000..625a99a9 --- /dev/null +++ b/src/Appointment/Plan/Command/SeedSegmentTemplatesCommand.php @@ -0,0 +1,151 @@ +}>> + */ + private const PRESETS = [ + 'beauty' => [ + ['name' => 'آماده‌سازی', 'minutes' => 10, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['room', 'operator']], + ['name' => 'بی‌حسی موضعی', 'minutes' => 15, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['room']], + ['name' => 'انتظار اثر', 'minutes' => 20, 'source' => 'fixed', 'present' => true, 'mergeable' => false, 'roles' => []], + ['name' => 'کار اصلی', 'minutes' => 0, 'source' => 'items', 'present' => true, 'mergeable' => false, 'roles' => ['room', 'operator']], + ['name' => 'تمیزکاری', 'minutes' => 10, 'source' => 'fixed', 'present' => false, 'mergeable' => false, 'roles' => ['room']], + ], + 'dental' => [ + ['name' => 'معاینه', 'minutes' => 10, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['room', 'doctor']], + ['name' => 'درمان', 'minutes' => 0, 'source' => 'items', 'present' => true, 'mergeable' => false, 'roles' => ['room', 'doctor']], + ['name' => 'ضدعفونی یونیت', 'minutes' => 15, 'source' => 'fixed', 'present' => false, 'mergeable' => false, 'roles' => ['room']], + ], + 'physio' => [ + ['name' => 'ارزیابی', 'minutes' => 15, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['doctor']], + ['name' => 'جلسهٔ درمان', 'minutes' => 0, 'source' => 'items', 'present' => true, 'mergeable' => false, 'roles' => ['room', 'operator']], + ['name' => 'استراحت', 'minutes' => 10, 'source' => 'fixed', 'present' => true, 'mergeable' => false, 'roles' => []], + ], + ]; + + public function __construct( + private readonly ServiceItemRepository $services, + private readonly SegmentTemplateRepository $templates, + private readonly ResourceTypeRepository $types, + private readonly EntityManagerInterface $em, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('service', null, InputOption::VALUE_REQUIRED, 'Service item uuid') + ->addOption('preset', null, InputOption::VALUE_REQUIRED, 'beauty | dental | physio', 'beauty') + ->addOption('force', null, InputOption::VALUE_NONE, 'Replace segments the service already has'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $preset = (string) $input->getOption('preset'); + + if (!isset(self::PRESETS[$preset])) { + $io->error(sprintf('الگوی «%s» وجود ندارد. یکی از: %s', $preset, implode('، ', array_keys(self::PRESETS)))); + + return Command::INVALID; + } + + $uuid = (string) $input->getOption('service'); + $service = $uuid === '' ? null : $this->services->findOneBy(['uuid' => $uuid]); + + if ($service === null) { + $io->error('سرویس یافت نشد؛ `--service=` را بدهید.'); + + return Command::INVALID; + } + + $existing = $this->templates->findForService($service); + + if ($existing !== [] && !$input->getOption('force')) { + $io->warning(sprintf( + 'این سرویس از قبل %d بخش دارد. برای جایگزینی `--force` بدهید.', + count($existing), + )); + + return Command::SUCCESS; + } + + foreach ($existing as $template) { + $this->em->remove($template); + } + + $entityType = $service->getSection()->getEntityType(); + $entityId = $service->getSection()->getEntityId(); + $missing = []; + $sequence = 0; + + foreach (self::PRESETS[$preset] as $row) { + $template = new SegmentTemplate($service, ++$sequence, $row['name']); + $template->setDuration($row['source'], $row['minutes']); + $template->setPatientPresent($row['present']); + $template->setMergeable($row['mergeable']); + + $this->em->persist($template); + + foreach ($row['roles'] as $code) { + $type = $this->types->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'code' => $code]); + + // نقشی که این محیط ندارد **ساخته نمی‌شود**: نوع منبع تصمیم کلینیک است و + // ساختن خاموشش یعنی فهرست نوع‌ها پر شود از چیزهایی که کسی نخواسته. + if ($type === null) { + $missing[$code] = true; + continue; + } + + $this->em->persist(new SegmentRequirement($template, $type)); + } + } + + $this->em->flush(); + + $io->success(sprintf('%d بخش برای «%s» ساخته شد.', $sequence, $service->getName())); + + if ($missing !== []) { + $io->note(sprintf( + 'این نقش‌ها در این محیط تعریف نشده‌اند و نیازمندی‌شان ساخته نشد: %s', + implode('، ', array_keys($missing)), + )); + } + + return Command::SUCCESS; + } +} diff --git a/src/Report/Service/ResourceUtilizationReporter.php b/src/Report/Service/ResourceUtilizationReporter.php index 5cc9a1fc..918425a2 100644 --- a/src/Report/Service/ResourceUtilizationReporter.php +++ b/src/Report/Service/ResourceUtilizationReporter.php @@ -42,11 +42,15 @@ final class ResourceUtilizationReporter $occupied = $this->occupiedMinutes($resources, $from, $to); $active = $this->activeMinutes($resources, $from, $to); + // تقویم همهٔ منابع هم دسته‌ای خوانده می‌شود؛ وگرنه هر منبع پنج کوئری اضافه + // می‌آورد و گزارشِ یک کلینیک متوسط دویست کوئری می‌شد. + $availability = $this->calendars->rawAvailabilityForAll($resources, $from, $to); + $rows = []; foreach ($resources as $resource) { $id = (int) $resource->getId(); - $available = $this->availableMinutes($resource, $from, $to); + $available = $this->availableMinutes($resource, $availability[$id] ?? []); $rows[] = $this->row( $resource, @@ -80,11 +84,9 @@ final class ResourceUtilizationReporter ]; } - private function availableMinutes(ClinicResource $resource, int $from, int $to): int + /** @param list<\App\Resource\ValueObject\DayAvailability> $days */ + private function availableMinutes(ClinicResource $resource, array $days): int { - // شعبه از خودِ منبع می‌آید؛ منبع بدون شعبه وجود ندارد. - $days = $this->calendars->rawAvailability($resource, $from, $to); - $minutes = 0; foreach ($days as $day) { diff --git a/src/Resource/Repository/ResourceCalendarRepository.php b/src/Resource/Repository/ResourceCalendarRepository.php index b438197c..7ba0db5a 100644 --- a/src/Resource/Repository/ResourceCalendarRepository.php +++ b/src/Resource/Repository/ResourceCalendarRepository.php @@ -29,6 +29,34 @@ class ResourceCalendarRepository extends ServiceEntityRepository ->getResult(); } + /** + * شیفت‌های چند منبع با **یک** کوئری — گزارش بهره‌وری روی چهل منبع، چهل کوئری نمی‌خواهد. + * + * @param int[] $resourceIds + * @return array> کلید: شناسهٔ منبع + */ + public function findForResources(array $resourceIds): array + { + if ($resourceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('c') + ->where('IDENTITY(c.resource) IN (:ids)') + ->setParameter('ids', $resourceIds) + ->orderBy('c.dayOfWeek', 'ASC') + ->addOrderBy('c.sequence', 'ASC') + ->getQuery() + ->getResult(); + + $byResource = []; + foreach ($rows as $row) { + $byResource[(int) $row->getResource()->getId()][] = $row; + } + + return $byResource; + } + public function deleteForResource(ClinicResource $resource): int { return (int) $this->createQueryBuilder('c') diff --git a/src/Resource/Repository/ResourceExceptionRepository.php b/src/Resource/Repository/ResourceExceptionRepository.php index 386f6cea..eea10f72 100644 --- a/src/Resource/Repository/ResourceExceptionRepository.php +++ b/src/Resource/Repository/ResourceExceptionRepository.php @@ -41,4 +41,35 @@ class ResourceExceptionRepository extends ServiceEntityRepository ->getQuery() ->getResult(); } + + /** + * استثناهای چند منبع با یک کوئری. + * + * @param int[] $resourceIds + * @return array> + */ + public function findOverlappingForResources(array $resourceIds, int $from, int $to): array + { + if ($resourceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('e') + ->where('IDENTITY(e.resource) IN (:ids)') + ->andWhere('e.startsAt < :to') + ->andWhere('e.endsAt > :from') + ->setParameter('ids', $resourceIds) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('e.startsAt', 'ASC') + ->getQuery() + ->getResult(); + + $byResource = []; + foreach ($rows as $row) { + $byResource[(int) $row->getResource()->getId()][] = $row; + } + + return $byResource; + } } diff --git a/src/Resource/Service/ResourceAvailabilityService.php b/src/Resource/Service/ResourceAvailabilityService.php index 5b3a7938..cbaa51e8 100644 --- a/src/Resource/Service/ResourceAvailabilityService.php +++ b/src/Resource/Service/ResourceAvailabilityService.php @@ -86,6 +86,75 @@ final class ResourceAvailabilityService return $days; } + /** + * همان `rawAvailability` برای چند منبع، ولی با خواندنِ دسته‌ای. + * + * تعطیلات و استثناهای محیط و ساعت شعبه برای همهٔ منابع یکی‌اند و بیرون حلقه خوانده + * می‌شوند؛ شیفت و استثنای هر منبع هم با یک کوئری برای همه می‌آید. بدون این، گزارشِ + * چهل منبع دویست کوئری می‌زد. + * + * @param ClinicResource[] $resources همهٔ آن‌ها باید یک شعبه داشته باشند + * @return array> کلید: شناسهٔ منبع + */ + public function rawAvailabilityForAll(array $resources, int $from, int $to): array + { + if ($resources === []) { + return []; + } + + $first = $resources[array_key_first($resources)]; + $timezone = new \DateTimeZone($first->getAddress()->getTimezone()); + $startDay = $this->midnight($from, $timezone); + $endDay = $this->midnight($to, $timezone); + + $holidayMap = $this->holidays->mapForRange($startDay, $endDay); + $overrideMap = $this->overrides->mapForRange( + $first->getEntityType(), + $first->getEntityId(), + $startDay, + $endDay, + ); + + $branchByDay = $this->branchHoursByDay($first); + + $ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources); + $shiftsById = $this->calendars->findForResources($ids); + $exceptionsById = $this->exceptions->findOverlappingForResources($ids, $startDay, $endDay + self::DAY_SECONDS); + + $out = []; + + foreach ($resources as $resource) { + $id = (int) $resource->getId(); + $byDay = []; + + foreach ($shiftsById[$id] ?? [] as $shift) { + if ($shift->isActive()) { + $byDay[$shift->getDayOfWeek()][] = new TimeInterval($shift->getStartMinute(), $shift->getEndMinute()); + } + } + + $shiftsByDay = array_map(TimeInterval::mergeAll(...), $byDay); + $days = []; + + for ($day = $startDay; $day <= $endDay; $day = $this->nextMidnight($day, $timezone)) { + $days[] = $this->buildDay( + $resource, + $day, + $timezone, + $shiftsByDay, + $branchByDay, + $holidayMap, + $overrideMap, + $exceptionsById[$id] ?? [], + ); + } + + $out[$id] = $days; + } + + return $out; + } + /** * @param array> $shiftsByDay * @param array>|null $branchByDay diff --git a/tests/Appointment/AppointmentPlanTest.php b/tests/Appointment/AppointmentPlanTest.php index 48cb2df7..d81b1105 100644 --- a/tests/Appointment/AppointmentPlanTest.php +++ b/tests/Appointment/AppointmentPlanTest.php @@ -435,6 +435,51 @@ class AppointmentPlanTest extends ApiTestCase ); } + /** + * ⭐ الگوی نمونه نقطهٔ شروع است، نه پیکربندی نهایی — و **بازنویسی خاموش نمی‌کند**. + */ + public function testSeedingCreatesAStarterSetAndRefusesToOverwrite(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $room = $this->resourceType($address, 'room', 'اتاق'); + $operator = $this->resourceType($address, 'operator', 'اپراتور'); + $this->resource($user, $address, $room, 'اتاق ۱'); + $this->resource($user, $address, $operator, 'اپراتور ۱'); + + $seed = static::getContainer()->get(\App\Appointment\Plan\Command\SeedSegmentTemplatesCommand::class); + $run = static function (array $input) use ($seed): array { + $tester = new \Symfony\Component\Console\Tester\CommandTester($seed); + $tester->execute($input); + + return [$tester->getStatusCode(), $tester->getDisplay()]; + }; + + [$code] = $run(['--service' => $service->getUuid(), '--preset' => 'beauty']); + self::assertSame(0, $code); + + $plan = $this->preview($user, $service, $address); + self::assertCount(5, $plan['data']['segments']); + self::assertSame('آماده‌سازی', $plan['data']['segments'][0]['name']); + + // بخشِ تمیزکاری بدون حضور بیمار است — همان چیزی که گزارش بهره‌وری با آن کار می‌کند. + $cleanup = end($plan['data']['segments']); + self::assertFalse($cleanup['patient_present']); + + // اجرای دوباره بدون `--force` دست به چیزی نمی‌زند. + [, $display] = $run(['--service' => $service->getUuid(), '--preset' => 'dental']); + self::assertStringContainsString('--force', $display); + + $unchanged = $this->preview($user, $service, $address); + self::assertCount(5, $unchanged['data']['segments'], 'بدون --force بازنویسی نمی‌شود'); + + [, $forced] = $run(['--service' => $service->getUuid(), '--preset' => 'dental', '--force' => true]); + self::assertStringNotContainsString('--force', $forced); + + $replaced = $this->preview($user, $service, $address); + self::assertCount(3, $replaced['data']['segments']); + } + public function testForeignServiceIsNotFound(): void { [$user, , $address] = $this->clinicWithBranch(); diff --git a/tests/Appointment/BookingLocationsScanTest.php b/tests/Appointment/BookingLocationsScanTest.php index 92c48b1c..242ec685 100644 --- a/tests/Appointment/BookingLocationsScanTest.php +++ b/tests/Appointment/BookingLocationsScanTest.php @@ -51,7 +51,9 @@ class BookingLocationsScanTest extends ApiTestCase for ($i = 0; $i < $clinicCount; $i++) { $clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC'])); $clinic->setName("کلینیک $i"); - $clinic->getDoctors()->add($doctor); + // پزشک را از همین EM می‌گیریم: اگر نمونهٔ دیگری باشد، Doctrine او را + // «موجودیت تازه» می‌بیند و flush با خطای cascade می‌شکند. + $clinic->getDoctors()->add($this->em->getRepository(Doctor::class)->find($doctor->getId())); $this->em->persist($clinic); $this->em->flush(); diff --git a/tests/Course/TreatmentCourseTest.php b/tests/Course/TreatmentCourseTest.php index 5055f7fc..87366e82 100644 --- a/tests/Course/TreatmentCourseTest.php +++ b/tests/Course/TreatmentCourseTest.php @@ -448,6 +448,69 @@ class TreatmentCourseTest extends ApiTestCase self::assertCount(8, $course->getSessions()->toArray()); } + /** + * ⭐ `book-all` همه یا هیچ است. + * + * تقویم فقط یک روزِ هفته باز است و فاصلهٔ پروتکل ۱ تا ۲ روز؛ پس جلسهٔ اول وقت پیدا + * می‌کند و جلسهٔ دوم نه. اگر تراکنش کار نکند، بیمار با یک نوبتِ تنها از یک دورهٔ + * هشت‌جلسه‌ای می‌ماند و هیچ‌کس نمی‌فهمد کجا قطع شد. + */ + public function testAFailedBookAllLeavesEverySessionPlanned(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section); + + $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ + 'address_uuid' => $address->getUuid(), + 'code' => 'room', + 'name' => 'اتاق', + ]); + self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); + + $resource = $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type['data']['uuid'], + 'name' => 'اتاق ۱', + ]); + self::assertSame(201, $this->responseCode()); + + // فقط شنبه‌ها باز است. + $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [ + 'days' => [6 => [['start_minute' => 540, 'end_minute' => 1020]]], + ]); + self::assertSame(200, $this->responseCode()); + + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [ + 'segments' => [ + ['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]], + ], + ]); + self::assertSame(200, $this->responseCode()); + + // بازهٔ ۱ تا ۲ روز: جلسهٔ دوم حتماً بیرون تنها روزِ باز می‌افتد. + $protocol = $this->protocol($user, $service, ['min_days' => 1, 'ideal_days' => 1, 'max_days' => 2]); + $started = $this->startCourse($user, $patient, $protocol['uuid']); + + $body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [ + 'branch_uuid' => $address->getUuid(), + 'doctor_uuid' => $doctor->getUuid(), + ]); + + self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + $this->em->clear(); + $course = $this->courseEntity($started['uuid']); + + foreach ($course->getSessions() as $session) { + self::assertSame( + CourseSession::STATUS_PLANNED, + $session->getStatus(), + sprintf('جلسهٔ %d نباید رزرو مانده باشد', $session->getSessionNumber()), + ); + self::assertNull($session->getAppointment()); + } + } + public function testAnotherClinicCannotSeeTheCourse(): void { [$owner, $section, , , $patient] = $this->clinicWithPatient(); diff --git a/tests/Package/PackageLedgerTest.php b/tests/Package/PackageLedgerTest.php index 98a8d498..9bea66ba 100644 --- a/tests/Package/PackageLedgerTest.php +++ b/tests/Package/PackageLedgerTest.php @@ -216,6 +216,44 @@ class PackageLedgerTest extends ApiTestCase self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance')); } + /** + * ⭐ `credit_refundable: false` اعتبار برگشته را پس می‌گیرد — **بدون** حذف ردیف. + * + * دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن + * `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد. + */ + public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section, 'لیزر'); + + // نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان + // نیست بلکه درونِ محدودهٔ جریمه می‌افتد — تنها حالتی که سیاست اعتبار اثر دارد. + $saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [ + 'free_window_hours' => 96, + 'penalty_mode' => 'none', + 'credit_refundable' => false, + ]); + self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE)); + + $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); + $appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); + + self::assertTrue($this->consumption()->consumeFor($appointment)); + + $body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']); + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertFalse($body['data']['credit_refundable']); + + $ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user); + $kinds = array_column($ledger['data']['rows'], 'kind'); + + self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمی‌شود'); + + $entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); + self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد'); + } + /** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */ public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void { diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php index e6312e67..477a8a9e 100644 --- a/tests/Report/ReportTest.php +++ b/tests/Report/ReportTest.php @@ -342,6 +342,63 @@ class ReportTest extends ApiTestCase $this->em->flush(); } + /** + * ⭐ اشغال و کار مفید هرکدام **یک** کوئری‌اند، مستقل از تعداد منبع. + * + * پیمایش per منبع روی کلینیکی با ۴۰ منبع یعنی ۸۰ کوئری برای یک گزارش. تعداد + * دقیقش مهم نیست؛ چیزی که این تست نگه می‌دارد این است که با سه برابر شدن منابع، + * تعداد کوئری‌ها سه برابر **نشود**. + */ + public function testQueryCountDoesNotGrowWithTheNumberOfResources(): void + { + [$user, , $address] = $this->clinic(); + + $type = $this->authJson('POST', '/api/v1/resource-types', $user, [ + 'address_uuid' => $address->getUuid(), + 'code' => 'room', + 'name' => 'اتاق', + ]); + self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE)); + + $reporter = static::getContainer()->get(\App\Report\Service\ResourceUtilizationReporter::class); + + $count = function (int $resources) use ($user, $address, $type, $reporter): int { + for ($i = 0; $i < $resources; $i++) { + $this->authJson('POST', '/api/v1/resource', $user, [ + 'address_uuid' => $address->getUuid(), + 'type_uuid' => $type['data']['uuid'], + 'name' => sprintf('اتاق %d', $i + 1), + ]); + self::assertSame(201, $this->responseCode()); + } + + $all = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class) + ->findBy(['address' => $address]); + + $connection = $this->em->getConnection(); + $before = $this->queryCount($connection); + + $reporter->report($all, $address, time() - 7 * 86400, time()); + + return $this->queryCount($connection) - $before; + }; + + $withOne = $count(1); + $withMany = $count(5); + + self::assertLessThan( + $withOne * 3, + $withMany, + sprintf('یک منبع %d کوئری، شش منبع %d کوئری — رشد خطی است', $withOne, $withMany), + ); + } + + /** شمار کوئری از خودِ سرور — `SHOW SESSION STATUS` روی همان اتصال. */ + private function queryCount(\Doctrine\DBAL\Connection $connection): int + { + return (int) ($connection->fetchAssociative("SHOW SESSION STATUS LIKE 'Questions'")['Value'] ?? 0); + } + // ── محدودیت بازه و دسترسی ─────────────────────────────────────────────── public function testARangeLongerThanNinetyDaysIsRejected(): void