perf(reports): read every resource's calendar in one batch, and close the owed tests
Writing the query-count test that task 14 owed showed the growth was real: one resource cost 10 queries, six cost 33 — about five per resource, because the available-minutes figure walked each resource's calendar on its own. Holidays, tenant overrides and branch hours are identical for every resource in a report, so they now load once outside the loop; shifts and exceptions load for all resources in one query each. The batched path is a new method rather than a change to rawAvailability, which the booking engine also calls. The test pins the shape of the growth, not an exact count. Also landed: - app:segment:seed-templates with beauty, dental and physio presets. Building four segments and their requirements by hand is the first thing a new clinic must do and the most tedious; this gives them something to edit instead of an empty page. It refuses to touch a service that already has segments unless --force, and it will not invent resource types the tenant never defined. - book-all is all-or-nothing, proven rather than asserted: with a calendar open one day a week and a 1-2 day protocol gap, session one finds a slot and session two cannot, and every session must come back planned. - credit_refundable: false takes the credit back with a negative adjustment and deletes nothing — the ledger stays append-only. - the segments editor has frontend tests, including that it sends back what the user sees and renders read-only without the permission. useBranches now returns [] for a non-array payload instead of throwing "branches.map is not a function" and taking the page down with it. BookingLocationsScanTest built a Clinic around a Doctor loaded from a different manager, which Doctrine treats as a new entity; it flushed fine most runs and failed on cascade in others. It now loads the doctor from the same manager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||||
|
const put = api.put as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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(<ServiceSegmentsTab serviceUuid="s-1" canEdit />);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('آمادهسازی')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** ⭐ ویرایشگر باید همان چیزی را بفرستد که کاربر میبیند، نه یک شکل تازه. */
|
||||||
|
it('sends the edited segments back on save', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
renderWithProviders(<ServiceSegmentsTab serviceUuid="s-1" canEdit />);
|
||||||
|
|
||||||
|
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(<ServiceSegmentsTab serviceUuid="s-1" canEdit={false} />);
|
||||||
|
|
||||||
|
const name = await screen.findByDisplayValue('آمادهسازی');
|
||||||
|
|
||||||
|
expect(name).toBeDisabled();
|
||||||
|
expect(screen.queryByRole('button', { name: /ذخیرهٔ بخشها/ })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,11 @@ export function useBranches() {
|
|||||||
onError: (e) => fail(e, 'بهروزرسانی شعبه ناموفق بود'),
|
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) {
|
export function useBranchWorkingHours(branchUuid: string | undefined) {
|
||||||
|
|||||||
@@ -147,10 +147,24 @@
|
|||||||
| `segment_templates` | جفت محیط (از بخشِ سرویس مشتق میشود) |
|
| `segment_templates` | جفت محیط (از بخشِ سرویس مشتق میشود) |
|
||||||
| `segment_requirements` | `AGGREGATE_CHILDREN` — ریشه `SegmentTemplate` |
|
| `segment_requirements` | `AGGREGATE_CHILDREN` — ریشه `SegmentTemplate` |
|
||||||
|
|
||||||
|
## الگوی نمونه
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec php bin/console app:segment:seed-templates --service=<uuid> --preset=beauty
|
||||||
|
```
|
||||||
|
|
||||||
|
سه الگو: `beauty` (آمادهسازی · بیحسی · انتظار · کار اصلی · تمیزکاری) · `dental`
|
||||||
|
(معاینه · درمان · ضدعفونی یونیت) · `physio` (ارزیابی · جلسهٔ درمان · استراحت).
|
||||||
|
|
||||||
|
نقطهٔ شروع است نه پیکربندی نهایی: کلینیک از روی چیزی که میبیند ویرایش میکند، نه از روی
|
||||||
|
صفحهٔ خالی. روی سرویسی که از قبل بخش دارد **کاری نمیکند** مگر `--force` — بازنویسی
|
||||||
|
خاموشِ چیزی که کلینیک خودش ساخته، بدترین رفتار ممکن است. نقشی که آن محیط تعریف نکرده،
|
||||||
|
ساخته نمیشود و در خروجی گزارش میشود؛ نوع منبع تصمیم کلینیک است.
|
||||||
|
|
||||||
## تستها
|
## تستها
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۴ تست
|
ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۵ تست
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -194,5 +194,5 @@
|
|||||||
## تستها
|
## تستها
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ddev exec php bin/phpunit tests/Cancellation # ۱۴ تست
|
ddev exec php bin/phpunit tests/Cancellation # ۱۷ تست
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -145,3 +145,14 @@
|
|||||||
```bash
|
```bash
|
||||||
ddev exec php bin/phpunit tests/Report # ۱۶ تست
|
ddev exec php bin/phpunit tests/Report # ۱۶ تست
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## هزینهٔ کوئری
|
||||||
|
|
||||||
|
`occupied` و `active` هرکدام یک کوئری `GROUP BY` اند، و تقویم همهٔ منابع هم دستهای خوانده
|
||||||
|
میشود (`ResourceAvailabilityService::rawAvailabilityForAll`): تعطیلات و استثناهای محیط و
|
||||||
|
ساعت شعبه برای همهٔ منابع یکیاند و بیرون حلقه میآیند، شیفت و استثنای هر منبع هم با یک
|
||||||
|
کوئری برای همه.
|
||||||
|
|
||||||
|
پیش از این هر منبع پنج کوئری اضافه میآورد و گزارشِ یک کلینیک چهلمنبعی دویست کوئری
|
||||||
|
میشد. تست `testQueryCountDoesNotGrowWithTheNumberOfResources` همین را نگه میدارد —
|
||||||
|
عددِ دقیق را پین نمیکند، فقط رشدِ خطی را رد میکند.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
| # | مورد | وضعیت | یادداشت |
|
| # | مورد | وضعیت | یادداشت |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۴.۱ | چهار جدول | ✅ | `Version20260730143130` |
|
| ۴.۱ | چهار جدول | ✅ | `Version20260730143130` |
|
||||||
| ۴.۲ | `valid_from`/`valid_to` روی شیفت از روز اول | ⏳ | شیفت فصلی پیاده نشد. دلیلِ «از روز اول لازم است» برقرار نیست: دو ستون تهیپذیر بعداً بدون backfill اضافه میشوند (NULL = همیشه معتبر). مقصد: هر تسکی که واقعاً شیفت فصلی بخواهد |
|
| ۴.۲ | `valid_from`/`valid_to` روی شیفت از روز اول | — | شیفت فصلی پیاده نشد. دلیلِ «از روز اول لازم است» برقرار نیست: دو ستون تهیپذیر بعداً بدون backfill اضافه میشوند (NULL = همیشه معتبر). مقصد: هر تسکی که واقعاً شیفت فصلی بخواهد |
|
||||||
| ۴.۳ | نوع `blocked` در استثناها هست | ✅ | `closure` همان نقش را دارد؛ چهار نوع: leave/absence/maintenance/closure |
|
| ۴.۳ | نوع `blocked` در استثناها هست | ✅ | `closure` همان نقش را دارد؛ چهار نوع: leave/absence/maintenance/closure |
|
||||||
| ۴.۴ | `national_holidays` در `GlobalTables::ENTITIES` با دلیل | ✅ | |
|
| ۴.۴ | `national_holidays` در `GlobalTables::ENTITIES` با دلیل | ✅ | |
|
||||||
| ۴.۵ | `resource_calendars` در `AGGREGATE_CHILDREN` | ✅ | |
|
| ۴.۵ | `resource_calendars` در `AGGREGATE_CHILDREN` | ✅ | |
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| ۱.۱۲ | سه endpoint | ✅ | `GET/PUT segments` + `POST appointment-plan/preview` |
|
| ۱.۱۲ | سه endpoint | ✅ | `GET/PUT segments` + `POST appointment-plan/preview` |
|
||||||
| ۱.۱۳ | `patient_facing_minutes` در پاسخ | ⚠️ | در API نیست؛ UI از `patient_present` هر بخش خودش جمع میزند |
|
| ۱.۱۳ | `patient_facing_minutes` در پاسخ | ⚠️ | در API نیست؛ UI از `patient_present` هر بخش خودش جمع میزند |
|
||||||
| ۱.۱۴ | سقفها | ⚠️ | ۴۸۰ دقیقه اعمال میشود؛ سقف ۲۰ بخش / ۱۰ نیازمندی / ۲۰ آیتم اعمال نشد |
|
| ۱.۱۴ | سقفها | ⚠️ | ۴۸۰ دقیقه اعمال میشود؛ سقف ۲۰ بخش / ۱۰ نیازمندی / ۲۰ آیتم اعمال نشد |
|
||||||
| ۱.۱۵ | `app:segment:seed-templates` | ⏳ | ساخته نشد؛ با UI تازه، الگوی نمونه دستی ساختنی است |
|
| ۱.۱۵ | `app:segment:seed-templates` | ✅ | سه الگو (`beauty`/`dental`/`physio`)؛ بدون `--force` بازنویسی نمیکند |
|
||||||
| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid | ✅ | |
|
| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid | ✅ | |
|
||||||
|
|
||||||
## ۲. دیتابیس
|
## ۲. دیتابیس
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
| ۴.۵ | سرویس بدون الگو | ✅ | ⭐ |
|
| ۴.۵ | سرویس بدون الگو | ✅ | ⭐ |
|
||||||
| ۴.۶ | حل نیازمندی — مهارت، بیکاندید، جنسیت، محیط دیگر | ✅ | |
|
| ۴.۶ | حل نیازمندی — مهارت، بیکاندید، جنسیت، محیط دیگر | ✅ | |
|
||||||
| ۴.۷ | سقفها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) |
|
| ۴.۷ | سقفها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) |
|
||||||
| ۴.۸ | تست فرانت ویرایشگر بخشها | ⏳ | تب ساخته شد ولی تست ندارد؛ منطق سنگینش در بکاند است که ۱۱ تست دارد |
|
| ۴.۸ | تست فرانت ویرایشگر بخشها | ✅ | بارگذاری، ذخیرهٔ همان چیزی که کاربر میبیند، و حالت فقطخواندنی |
|
||||||
|
|
||||||
**اجرا:** `ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php` → ۱۱ تست.
|
**اجرا:** `ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php` → ۱۱ تست.
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||||
| ۰.۲ | `PatientSession` موجود دستنخورده | ✅ | «مراجعهٔ انجامشده» ≠ «جلسهٔ دوره»؛ هیچ فایلی از `src/Patient` تغییر نکرد |
|
| ۰.۲ | `PatientSession` موجود دستنخورده | ✅ | «مراجعهٔ انجامشده» ≠ «جلسهٔ دوره»؛ هیچ فایلی از `src/Patient` تغییر نکرد |
|
||||||
| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر میشوند | ⏳ | تسک ۱۴ رویدادها را میسازد؛ فعلاً `book-all` هیچ رویدادی منتشر نمیکند، پس خطر «هشت پیامک در rollback» وجود ندارد |
|
| ۰.۳ | رویدادهای تسک ۰۷ بعد از commit منتشر میشوند | ✅ | صندوق خروجی تسک ۱۴ همین را تضمین میکند: `record()` فلاش نمیکند، پس rollbackِ `book-all` رویدادی جا نمیگذارد |
|
||||||
| ۰.۴ | `abandon` نوبتهای `booked` را لغو نمیکند | ✅ | مستند شد؛ لغو ظرفیت باید تصمیم صریح باشد نه اثر جانبی |
|
| ۰.۴ | `abandon` نوبتهای `booked` را لغو نمیکند | ✅ | مستند شد؛ لغو ظرفیت باید تصمیم صریح باشد نه اثر جانبی |
|
||||||
|
|
||||||
## ۱. بکاند
|
## ۱. بکاند
|
||||||
@@ -79,8 +79,8 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | |
|
| ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | |
|
||||||
| ۴.۲ | snapshot پروتکل | ✅ | ⭐ |
|
| ۴.۲ | snapshot پروتکل | ✅ | ⭐ |
|
||||||
| ۴.۳ | لنگر متحرک و نزدیکترین به ایدهآل | ⚠️ | لنگر پیشنهاد و محاسبهٔ افق تست شد؛ اجرای کامل `book-all` با منابع و ساعت کاری هنوز تست ندارد |
|
| ۴.۳ | لنگر متحرک و نزدیکترین به ایدهآل | ⚠️ | لنگر پیشنهاد، افق، و مسیر شکستِ `book-all` تست دارند؛ مسیر موفقِ چندجلسهای هنوز نه |
|
||||||
| ۴.۴ | شکست جلسهٔ N → rollback | ⏳ | با ۴.۳ یک بسته است |
|
| ۴.۴ | شکست جلسهٔ N → rollback | ✅ | ⭐ تقویم فقط یکروزه: جلسهٔ اول وقت پیدا میکند، دومی نه، و **هیچ** جلسهای رزرو نمیماند |
|
||||||
| ۴.۵ | سقف ۹۰ روز | ✅ | `testSessionsBeyondTheHorizonAreSkippedNotFailed` — جلسهٔ بیرون افق رد میشود، دوره دستنخورده میماند |
|
| ۴.۵ | سقف ۹۰ روز | ✅ | `testSessionsBeyondTheHorizonAreSkippedNotFailed` — جلسهٔ بیرون افق رد میشود، دوره دستنخورده میماند |
|
||||||
| ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ |
|
| ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ |
|
||||||
| ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` |
|
| ۴.۷ | پیشرفت دوره | ✅ | «۳ از ۸» + `next_params` |
|
||||||
|
|||||||
@@ -53,10 +53,10 @@
|
|||||||
| ۳.۴ | سقف `notify_count` | ✅ | ۳ بار |
|
| ۳.۴ | سقف `notify_count` | ✅ | ۳ بار |
|
||||||
| ۳.۵ | پیامک async بیرون تراکنش لغو | ✅ | ⭐ `dispatchAsync` روی messenger؛ لغو تراکنش سراسری هم ندارد (۱.۷) |
|
| ۳.۵ | پیامک async بیرون تراکنش لغو | ✅ | ⭐ `dispatchAsync` روی messenger؛ لغو تراکنش سراسری هم ندارد (۱.۷) |
|
||||||
| ۳.۶ | ترتیب `priority DESC, created_at ASC` | ✅ | |
|
| ۳.۶ | ترتیب `priority DESC, created_at ASC` | ✅ | |
|
||||||
| ۳.۷ | فیلتر `preferred_day_parts` | ⏳ | ذخیره و نمایش میشود ولی در تطبیق اعمال نمیشود — بدون منطقهٔ زمانی شعبه، «عصر» تعریف قطعی ندارد؛ به تسک ۱۴ موکول شد |
|
| ۳.۷ | فیلتر `preferred_day_parts` | ✅ | ⭐ مرزها در `WaitlistEntry::DAY_PARTS` با ساعت **محلی شعبه**؛ فیلتر پیش از بریدن به ده نفر اعمال میشود |
|
||||||
| ۳.۸ | `converted` خودکار روی رزرو بیمار | ⏳ | نیازمند رویداد `AppointmentBooked` که تسک ۱۴ میسازد |
|
| ۳.۸ | `converted` خودکار روی رزرو بیمار | ✅ | ⭐ از رویداد `AppointmentBooked`، نه از داخل `BookingService` — تبدیل نباید بتواند نوبت واقعی را rollback کند |
|
||||||
| ۳.۹ | `app:waitlist:expire` روزانه | ⏳ | ردیف منقضی در تطبیق نمیآید (`desiredTo >= now`)، پس اثر عملی ندارد؛ پاکسازی با تسک ۱۴ |
|
| ۳.۹ | `app:waitlist:expire` روزانه | ✅ | سرویس + دستور + پیام روزانهٔ زمانبند؛ وضعیت عوض میشود نه حذف |
|
||||||
| ۳.۱۰ | بازهٔ بیش از ۹۰ روز → ۴۲۲ | ⏳ | فقط بازهٔ گذشته و وارونه رد میشود |
|
| ۳.۱۰ | بازهٔ بیش از ۹۰ روز → ۴۲۲ | ✅ | همان افق رزرو تسک ۱۲ |
|
||||||
| ۳.۱۱ | هفت endpoint | ✅ | ۱۰ تا: سیاست GET/PUT + override + preview + cancel + no-show + لیست انتظار GET/POST/DELETE/matches |
|
| ۳.۱۱ | هفت endpoint | ✅ | ۱۰ تا: سیاست GET/PUT + override + preview + cancel + no-show + لیست انتظار GET/POST/DELETE/matches |
|
||||||
|
|
||||||
## ۴. دیتابیس
|
## ۴. دیتابیس
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
| ۴.۲ | ایندکس تطبیق لیست انتظار | ✅ | |
|
| ۴.۲ | ایندکس تطبیق لیست انتظار | ✅ | |
|
||||||
| ۴.۳ | ایندکس پنجرهٔ عدم حضور | ✅ | |
|
| ۴.۳ | ایندکس پنجرهٔ عدم حضور | ✅ | |
|
||||||
| ۴.۴ | `risk_tag_uuid` بدون FK | ✅ | همان الگوی موجود پروژه |
|
| ۴.۴ | `risk_tag_uuid` بدون FK | ✅ | همان الگوی موجود پروژه |
|
||||||
| ۴.۵ | دستور seed سیاست پیشفرض | ⏳ | لازم نشد: نبودِ سیاست یعنی «بدون جریمه»، پس رفتار پیشفرض از قبل امن است |
|
| ۴.۵ | دستور seed سیاست پیشفرض | — | لازم نشد: نبودِ سیاست یعنی «بدون جریمه»، پس رفتار پیشفرض از قبل امن است |
|
||||||
| ۴.۶ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
| ۴.۶ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||||
|
|
||||||
## ۵. UI
|
## ۵. UI
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
| ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویسها ساخته نشد (اندپوینتش هست) |
|
| ۵.۱ | `CancellationPolicyPage` | ⚠️ | سیاست محیط کامل است؛ جدول override سرویسها ساخته نشد (اندپوینتش هست) |
|
||||||
| ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) |
|
| ۵.۲ | `WaitlistPage` | ⚠️ | لیست با فیلتر وضعیت هست؛ تب «قابل تطبیق» ساخته نشد (اندپوینت `matches` هست) |
|
||||||
| ۵.۳ | دکمهٔ لغو با محتوای preview | ✅ | ⭐ `CancelAppointmentDialog` جریمه و بازگشت اعتبار را **پیش از** تأیید نشان میدهد؛ سرویس لغو هم `reason` میگیرد و ردیف تایملاین مینویسد (قبلاً این مسیر هیچ ردی نمیگذاشت) |
|
| ۵.۳ | دکمهٔ لغو با محتوای preview | ✅ | ⭐ `CancelAppointmentDialog` جریمه و بازگشت اعتبار را **پیش از** تأیید نشان میدهد؛ سرویس لغو هم `reason` میگیرد و ردیف تایملاین مینویسد (قبلاً این مسیر هیچ ردی نمیگذاشت) |
|
||||||
| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ⏳ | برچسب از `TenantTag` میآید و در پرونده دیده میشود، ولی شمارش عدم حضور نمایش داده نمیشود |
|
| ۵.۴ | نشان پرریسک در پروندهٔ بیمار | ✅ | ⭐ `GET /patient/{uuid}/no-shows` + نشان در بنر پرونده؛ فقط وقتی `count > 0` |
|
||||||
| ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دستساز ساخته نشد |
|
| ۵.۵ | `ConfirmDialog` موجود | ✅ | جای دیگری مودال دستساز ساخته نشد |
|
||||||
| ۵.۶ | فیلتر در URL | ✅ | `useUrlState` |
|
| ۵.۶ | فیلتر در URL | ✅ | `useUrlState` |
|
||||||
| ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` |
|
| ۵.۷ | تاریخ شمسی و مبلغ | ✅ | `formatDate` · `PriceInput` |
|
||||||
@@ -94,14 +94,14 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۶.۱ | محاسبهٔ جریمه — پنج حالت | ✅ | ⭐ داخل پنجره، بیرون پنجره، کلینیک، سقف پرداختی، بدون سیاست |
|
| ۶.۱ | محاسبهٔ جریمه — پنج حالت | ✅ | ⭐ داخل پنجره، بیرون پنجره، کلینیک، سقف پرداختی، بدون سیاست |
|
||||||
| ۶.۲ | لغو — آزادسازی، کیف پول، ۴۰۹، گذشته ۴۲۲ | ✅ | + «موجودی ناکافی لغو را شکست نمیدهد» |
|
| ۶.۲ | لغو — آزادسازی، کیف پول، ۴۰۹، گذشته ۴۲۲ | ✅ | + «موجودی ناکافی لغو را شکست نمیدهد» |
|
||||||
| ۶.۳ | اولویت سیاست سرویس بر محیط | ⏳ | `resolve()` نوشته شد ولی تست اختصاصی ندارد |
|
| ۶.۳ | اولویت سیاست سرویس بر محیط | ✅ | از راه عددِ خروجی سنجیده میشود نه از راه resolver |
|
||||||
| ۶.۴ | عدم حضور — سوم برچسب، دوبار یک رکورد | ✅ | ⭐ پنجرهٔ ۱۲ ماه تست نشد |
|
| ۶.۴ | عدم حضور — سوم برچسب، دوبار یک رکورد | ✅ | ⭐ پنجرهٔ ۱۲ ماه تست نشد |
|
||||||
| ۶.۵ | بیمار پرریسک رزرو موفق دارد | ⏳ | برچسب هیچجا بررسی نمیشود، پس مسدودسازی ممکن نیست |
|
| ۶.۵ | بیمار پرریسک رزرو موفق دارد | ✅ | `testATaggedPatientCanStillBook` |
|
||||||
| ۶.۶ | لیست انتظار — ترتیب، سقف اطلاع، شعبه | ✅ | فیلتر روزبخش تست نشد (۳.۷) |
|
| ۶.۶ | لیست انتظار — ترتیب، سقف اطلاع، شعبه | ✅ | فیلتر روزبخش تست نشد (۳.۷) |
|
||||||
| ۶.۷ | تبدیل به رزرو | ⏳ | با ۳.۸ |
|
| ۶.۷ | تبدیل به رزرو | ✅ | تبدیل تنگ + idempotent، هر دو تست دارند |
|
||||||
| ۶.۸ | شکست پیامک لغو را rollback نمیکند | ⚠️ | معماریاش تضمین میکند (async، بدون تراکنش سراسری) ولی تست تزریق خطا نوشته نشد |
|
| ۶.۸ | شکست پیامک لغو را rollback نمیکند | ⚠️ | معماریاش تضمین میکند (async، بدون تراکنش سراسری) ولی تست تزریق خطا نوشته نشد |
|
||||||
| ۶.۹ | تست کیف پول موجود سبز ماند | ✅ | ⭐ |
|
| ۶.۹ | تست کیف پول موجود سبز ماند | ✅ | ⭐ |
|
||||||
| ۶.۱۰ | سیاست اعتبار روی دوره | ⏳ | مسیرش هست (`credit_refundable`)، تست ترکیبی با دوره نوشته نشد |
|
| ۶.۱۰ | سیاست اعتبار روی دوره | ✅ | `credit_refundable: false` با ردیف `adjustment` پس میگیرد؛ هیچ ردیفی حذف نمیشود |
|
||||||
|
|
||||||
**اجرا:** `tests/Cancellation` → ۱۴ تست · `tests/Waitlist` → ۹ تست.
|
**اجرا:** `tests/Cancellation` → ۱۴ تست · `tests/Waitlist` → ۹ تست.
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
| ۸.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۳۲ تست |
|
| ۸.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۳۲ تست |
|
||||||
| ۸.۶ | تستهای tenant سبز | ✅ | |
|
| ۸.۶ | تستهای tenant سبز | ✅ | |
|
||||||
| ۸.۷ | `docs/api/*` بهروز | ✅ | |
|
| ۸.۷ | `docs/api/*` بهروز | ✅ | |
|
||||||
| ۸.۸ | چکلیست UI کامل | ⚠️ | جز ۵.۱، ۵.۲، ۵.۴ |
|
| ۸.۸ | چکلیست UI کامل | ⚠️ | جز ۵.۱ (جدول override) و ۵.۲ (تب «قابل تطبیق») |
|
||||||
| ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینتها پنلمحورند؛ اتصال `nobat724_front` بررسی نشد |
|
| ۸.۹ | سایت باید preview لغو را نشان دهد | ⏳ | اندپوینتها پنلمحورند؛ اتصال `nobat724_front` بررسی نشد |
|
||||||
| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان |
|
| ۸.۱۰ | `clinic-pro-tauri` بررسی شد | ⏳ | همان |
|
||||||
| ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
|
| ۸.۱۱ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
| ۲.۵ | `released` شمرده نمیشود | ✅ | `BLOCKING_STATUSES` |
|
| ۲.۵ | `released` شمرده نمیشود | ✅ | `BLOCKING_STATUSES` |
|
||||||
| ۲.۶ | `available = 0` → `utilization = null` | ✅ | ⭐ تست دارد |
|
| ۲.۶ | `available = 0` → `utilization = null` | ✅ | ⭐ تست دارد |
|
||||||
| ۲.۷ | مرز بازه | ✅ | همپوشانی بازهای (`start < to AND end > from`) — دقیقتر از مرز روی یک سر |
|
| ۲.۷ | مرز بازه | ✅ | همپوشانی بازهای (`start < to AND end > from`) — دقیقتر از مرز روی یک سر |
|
||||||
| ۲.۸ | کوئری تجمعی بدون پیمایش | ⚠️ | `occupied` و `active` هر کدام یک کوئری `GROUP BY` اند؛ ولی `available` per منبع از تقویم خوانده میشود (منطق شیفت/تعطیلات در SQL نمیآید) |
|
| ۲.۸ | کوئری تجمعی بدون پیمایش | ✅ | ⭐ `rawAvailabilityForAll` تقویم همهٔ منابع را دستهای میخواند؛ تعطیلات/ساعت شعبه بیرون حلقه |
|
||||||
| ۲.۹ | تأیید وجود دادهٔ واقعی پیش از پیادهسازی | ✅ | ⭐ `patient_sessions` زمان شروع/پایان مراجعه ندارد، پس مبنای «واقعی» فاصلهٔ اسلات شد و همین در سند نوشته شد |
|
| ۲.۹ | تأیید وجود دادهٔ واقعی پیش از پیادهسازی | ✅ | ⭐ `patient_sessions` زمان شروع/پایان مراجعه ندارد، پس مبنای «واقعی» فاصلهٔ اسلات شد و همین در سند نوشته شد |
|
||||||
| ۲.۱۰ | آستانههای شدت | ✅ | ۳۰/۱۵/۵ درصد |
|
| ۲.۱۰ | آستانههای شدت | ✅ | ۳۰/۱۵/۵ درصد |
|
||||||
| ۲.۱۱ | انحراف منفی هم `high` | ✅ | ⭐ قدر مطلق |
|
| ۲.۱۱ | انحراف منفی هم `high` | ✅ | ⭐ قدر مطلق |
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
| ۴.۵ | `utilization = null` → `—` با توضیح | ⚠️ | `—` و `title` هست؛ لینک «تنظیم تقویم» اضافه نشد |
|
| ۴.۵ | `utilization = null` → `—` با توضیح | ⚠️ | `—` و `title` هست؛ لینک «تنظیم تقویم» اضافه نشد |
|
||||||
| ۴.۶ | لینک اصلاح از `PlanAccuracyPage` | ✅ | ⭐ «ویرایش بخشهای این خدمت» |
|
| ۴.۶ | لینک اصلاح از `PlanAccuracyPage` | ✅ | ⭐ «ویرایش بخشهای این خدمت» |
|
||||||
| ۴.۷ | بازه با `PersianDatePicker` | ⚠️ | انتخابگر بازهٔ آماده (هفته/ماه/سهماه) — برای گزارشی که همیشه «تا امروز» است سادهتر و کمخطاتر |
|
| ۴.۷ | بازه با `PersianDatePicker` | ⚠️ | انتخابگر بازهٔ آماده (هفته/ماه/سهماه) — برای گزارشی که همیشه «تا امروز» است سادهتر و کمخطاتر |
|
||||||
| ۴.۸ | وضعیت در URL | ⏳ | بازه و شعبه در state محلیاند |
|
| ۴.۸ | وضعیت در URL | ✅ | `useUrlState` روی هر دو گزارش |
|
||||||
| ۴.۹ | `DataTable` با skeleton و empty state | ✅ | |
|
| ۴.۹ | `DataTable` با skeleton و empty state | ✅ | |
|
||||||
| ۴.۱۰ | رنگ نمودار از توکنها | — | نمودار ندارد (۴.۱) |
|
| ۴.۱۰ | رنگ نمودار از توکنها | — | نمودار ندارد (۴.۱) |
|
||||||
| ۴.۱۱ | هیچ رنگ hard-code | ✅ | |
|
| ۴.۱۱ | هیچ رنگ hard-code | ✅ | |
|
||||||
@@ -88,10 +88,10 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۵.۱ | صندوق خروجی — rollback، انتشار، شکست، سقف تلاش | ✅ | ⭐ |
|
| ۵.۱ | صندوق خروجی — rollback، انتشار، شکست، سقف تلاش | ✅ | ⭐ |
|
||||||
| ۵.۲ | payload فقط اسکالر | ✅ | مقادیر تودرتو و object حذف میشوند |
|
| ۵.۲ | payload فقط اسکالر | ✅ | مقادیر تودرتو و object حذف میشوند |
|
||||||
| ۵.۳ | بهرهوری — سنجهها | ⚠️ | `utilization = null` تست شد؛ سناریوی کامل با اشغال واقعی و `capacity` تست نشد (نیازمند نوبت با بخشهای ثبتشده) |
|
| ۵.۳ | بهرهوری — سنجهها | ✅ | ⭐ اشغال شامل انتظار، «کار مفید» نه — با نوبت و بخشهای واقعی |
|
||||||
| ۵.۴ | دقت برنامه — انحراف دوطرفه و نمونهٔ کم | ✅ | ⭐ |
|
| ۵.۴ | دقت برنامه — انحراف دوطرفه و نمونهٔ کم | ✅ | ⭐ |
|
||||||
| ۵.۵ | دسترسی و بازه | ✅ | ۴۲۲ بازه، ۴۰۳ رویدادها، جداسازی محیط |
|
| ۵.۵ | دسترسی و بازه | ✅ | ۴۲۲ بازه، ۴۰۳ رویدادها، جداسازی محیط |
|
||||||
| ۵.۶ | تعداد کوئری مستقل از تعداد منبع | ⏳ | با ۲.۸ یک بسته است |
|
| ۵.۶ | تعداد کوئری مستقل از تعداد منبع | ✅ | رشدِ خطی رد میشود؛ عددِ دقیق پین نمیشود |
|
||||||
|
|
||||||
**اجرا:** `ddev exec php bin/phpunit tests/Report` → ۱۶ تست.
|
**اجرا:** `ddev exec php bin/phpunit tests/Report` → ۱۶ تست.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Appointment\Plan\Command;
|
||||||
|
|
||||||
|
use App\Appointment\Plan\Entity\SegmentRequirement;
|
||||||
|
use App\Appointment\Plan\Entity\SegmentTemplate;
|
||||||
|
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
|
||||||
|
use App\ClinicService\Repository\ServiceItemRepository;
|
||||||
|
use App\Resource\Repository\ResourceTypeRepository;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* الگوی نمونهٔ بخشها برای یک سرویس — نقطهٔ شروع، نه پیکربندی نهایی.
|
||||||
|
*
|
||||||
|
* ساختنِ چهار بخش با نیازمندیهایشان از صفر، اولین کاری است که کلینیک تازه باید بکند و
|
||||||
|
* بیحوصلهکنندهترینش. این دستور همان را در یک خط میسازد تا کلینیک از روی چیزی که
|
||||||
|
* میبیند ویرایش کند، نه از روی صفحهٔ خالی.
|
||||||
|
*
|
||||||
|
* روی سرویسی که از قبل بخش دارد **کاری نمیکند** مگر `--force`: بازنویسی خاموشِ چیزی که
|
||||||
|
* کلینیک خودش ساخته، بدترین رفتار ممکن است.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:segment:seed-templates',
|
||||||
|
description: 'Create a starter set of appointment segments for a service.',
|
||||||
|
)]
|
||||||
|
class SeedSegmentTemplatesCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* سه الگو از سه حوزهٔ واقعی. `items` یعنی مدت از خودِ سرویسهای انتخابشده میآید.
|
||||||
|
*
|
||||||
|
* @var array<string, list<array{name: string, minutes: int, source: string, present: bool, mergeable: bool, roles: list<string>}>>
|
||||||
|
*/
|
||||||
|
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=<uuid>` را بدهید.');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,11 +42,15 @@ final class ResourceUtilizationReporter
|
|||||||
$occupied = $this->occupiedMinutes($resources, $from, $to);
|
$occupied = $this->occupiedMinutes($resources, $from, $to);
|
||||||
$active = $this->activeMinutes($resources, $from, $to);
|
$active = $this->activeMinutes($resources, $from, $to);
|
||||||
|
|
||||||
|
// تقویم همهٔ منابع هم دستهای خوانده میشود؛ وگرنه هر منبع پنج کوئری اضافه
|
||||||
|
// میآورد و گزارشِ یک کلینیک متوسط دویست کوئری میشد.
|
||||||
|
$availability = $this->calendars->rawAvailabilityForAll($resources, $from, $to);
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
|
|
||||||
foreach ($resources as $resource) {
|
foreach ($resources as $resource) {
|
||||||
$id = (int) $resource->getId();
|
$id = (int) $resource->getId();
|
||||||
$available = $this->availableMinutes($resource, $from, $to);
|
$available = $this->availableMinutes($resource, $availability[$id] ?? []);
|
||||||
|
|
||||||
$rows[] = $this->row(
|
$rows[] = $this->row(
|
||||||
$resource,
|
$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;
|
$minutes = 0;
|
||||||
|
|
||||||
foreach ($days as $day) {
|
foreach ($days as $day) {
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ class ResourceCalendarRepository extends ServiceEntityRepository
|
|||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شیفتهای چند منبع با **یک** کوئری — گزارش بهرهوری روی چهل منبع، چهل کوئری نمیخواهد.
|
||||||
|
*
|
||||||
|
* @param int[] $resourceIds
|
||||||
|
* @return array<int, list<\App\Resource\Entity\ResourceCalendar>> کلید: شناسهٔ منبع
|
||||||
|
*/
|
||||||
|
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
|
public function deleteForResource(ClinicResource $resource): int
|
||||||
{
|
{
|
||||||
return (int) $this->createQueryBuilder('c')
|
return (int) $this->createQueryBuilder('c')
|
||||||
|
|||||||
@@ -41,4 +41,35 @@ class ResourceExceptionRepository extends ServiceEntityRepository
|
|||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult();
|
->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* استثناهای چند منبع با یک کوئری.
|
||||||
|
*
|
||||||
|
* @param int[] $resourceIds
|
||||||
|
* @return array<int, list<\App\Resource\Entity\ResourceException>>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,75 @@ final class ResourceAvailabilityService
|
|||||||
return $days;
|
return $days;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان `rawAvailability` برای چند منبع، ولی با خواندنِ دستهای.
|
||||||
|
*
|
||||||
|
* تعطیلات و استثناهای محیط و ساعت شعبه برای همهٔ منابع یکیاند و بیرون حلقه خوانده
|
||||||
|
* میشوند؛ شیفت و استثنای هر منبع هم با یک کوئری برای همه میآید. بدون این، گزارشِ
|
||||||
|
* چهل منبع دویست کوئری میزد.
|
||||||
|
*
|
||||||
|
* @param ClinicResource[] $resources همهٔ آنها باید یک شعبه داشته باشند
|
||||||
|
* @return array<int, list<DayAvailability>> کلید: شناسهٔ منبع
|
||||||
|
*/
|
||||||
|
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<int, list<TimeInterval>> $shiftsByDay
|
* @param array<int, list<TimeInterval>> $shiftsByDay
|
||||||
* @param array<int, list<TimeInterval>>|null $branchByDay
|
* @param array<int, list<TimeInterval>>|null $branchByDay
|
||||||
|
|||||||
@@ -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
|
public function testForeignServiceIsNotFound(): void
|
||||||
{
|
{
|
||||||
[$user, , $address] = $this->clinicWithBranch();
|
[$user, , $address] = $this->clinicWithBranch();
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ class BookingLocationsScanTest extends ApiTestCase
|
|||||||
for ($i = 0; $i < $clinicCount; $i++) {
|
for ($i = 0; $i < $clinicCount; $i++) {
|
||||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||||
$clinic->setName("کلینیک $i");
|
$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->persist($clinic);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -448,6 +448,69 @@ class TreatmentCourseTest extends ApiTestCase
|
|||||||
self::assertCount(8, $course->getSessions()->toArray());
|
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
|
public function testAnotherClinicCannotSeeTheCourse(): void
|
||||||
{
|
{
|
||||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||||
|
|||||||
@@ -216,6 +216,44 @@ class PackageLedgerTest extends ApiTestCase
|
|||||||
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
|
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 است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
|
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
|
||||||
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
|
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -342,6 +342,63 @@ class ReportTest extends ApiTestCase
|
|||||||
$this->em->flush();
|
$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
|
public function testARangeLongerThanNinetyDaysIsRejected(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user