diff --git a/.claude/prompt/patient-record-number-pattern.md b/.claude/prompt/patient-record-number-pattern.md new file mode 100644 index 00000000..3e400ca4 --- /dev/null +++ b/.claude/prompt/patient-record-number-pattern.md @@ -0,0 +1,327 @@ +# الگوی شماره پرونده — تنظیمات per-tenant + تولید خودکار شماره پرونده + +## پروژه + +`clinicpro` (Backend Symfony + پنل ادمین React). cross-repo نیست: `record_number` در `nobat724_front` مصرف نمی‌شود (بررسی شد). + +## زمینه + +`PatientRecord` از قبل ستون `record_number` دارد، ولی امروز: + +- **متن آزاد و دستی** است: کاربر در فرم ساخت پرونده تایپش می‌کند و zod فقط `min(1)` را چک می‌کند. +- **هیچ قید یکتایی ندارد**: تنها unique روی `patient_records` جفت `(entity_type, entity_id, user_id)` است، نه شماره پرونده. دو پرونده می‌توانند شمارهٔ یکسان بگیرند. +- **پرونده‌های خودکار اصلاً شماره نمی‌گیرند**: وقتی نوبت قطعی می‌شود، `PatientService` پرونده را `new PatientRecord(...)` می‌سازد و `record_number` را `null` می‌گذارد. یعنی بخش بزرگی از پرونده‌ها بی‌شماره‌اند. + +صاحب کلینیک/پزشک می‌خواهد یک **روند** داشته باشد: الگوی شماره را یک‌بار تعریف کند و از آن به بعد هر پرونده‌ای که ثبت می‌شود خودش شماره بگیرد. + +## مشکل / هدف + +۱. یک تنظیمِ per-tenant «الگوی شماره پرونده» با توکن‌های مشخص و شمارندهٔ خودکار. +۲. تولید شمارهٔ پرونده **سمت سرور** در **هر دو** مسیر ساخت پرونده (دستی از پنل، خودکار از نوبت). +۳. یکتایی شماره در هر محیط، حتی زیر درخواست‌های هم‌زمان. + +### تصمیم‌های گرفته‌شده (توسط کاربر — تغییرشان ندهید) + +| تصمیم | مقدار | +|---|---| +| ریست شمارنده | قابل انتخاب: `none` / `yearly` / `monthly` (بر مبنای تقویم **شمسی**) | +| ورود دستی شماره | فقط **صاحب محیط** (پزشکِ مالک مطب / مالک کلینیک) و `ROLE_ADMIN`. منشی و پرسنل و پزشکِ مهمان → فقط شمارهٔ تولیدشده | +| پرونده‌های قدیمیِ بی‌شماره | **lazy backfill**: اولین بار که پرونده باز می‌شود (`GET /api/v1/patient/{uuid}`) شماره می‌گیرد | + +## معیار پذیرش + +- ✅ **موفق (تنظیمات):** `PUT /api/v1/patient-record-number-settings` با بدنهٔ `{enabled: true, pattern: "MD-{YY}-{SEQ:4}", reset_policy: "yearly"}` توسط مالک کلینیک → `200` و `data.preview === "MD-05-0001"` (برای سال ۱۴۰۵ و شمارندهٔ صفر). `GET` همان مسیر مقدار ذخیره‌شده + `next_preview` را برمی‌گرداند. +- ✅ **موفق (ساخت دستی):** با الگوی فعال، `POST /api/v1/patient` بدون فیلد `record_number` → `201` و `data.record_number === "MD-05-0001"`؛ پروندهٔ بعدی `MD-05-0002`. +- ✅ **موفق (ساخت خودکار):** قطعی‌کردن یک نوبت که پرونده ندارد → پروندهٔ ساخته‌شده توسط `PatientService` هم `record_number` غیر `null` دارد و در همان دنبالهٔ شماره‌ها است. +- ✅ **موفق (backfill تنبل):** پرونده‌ای با `record_number = null` که پیش از فعال‌شدن الگو ساخته شده، بعد از یک `GET /api/v1/patient/{uuid}` شماره می‌گیرد و در `GET` دوم **همان** شماره برمی‌گردد (نه شمارهٔ تازه). +- ❌ **خطا (الگوی نامعتبر):** `PUT` با `pattern: "MD-{FOO}"` → `422` با `field: pattern` و پیام فارسی؛ `pattern` بدون هیچ `{SEQ}` → `422` (وگرنه همهٔ پرونده‌ها یک شماره می‌گرفتند). +- ❌ **خطا (دسترسی):** منشیِ همان کلینیک روی `PUT` تنظیمات → `403`. منشی‌ای که در `POST /api/v1/patient` فیلد `record_number` می‌فرستد → `403` (یا فیلد بی‌صدا نادیده گرفته نشود؛ خطا صریح باشد). +- ⚠️ **مرزی (هم‌زمانی):** دو `POST /api/v1/patient` هم‌زمان در یک محیط → دو شمارهٔ **متفاوت**؛ هیچ‌کدام ۵۰۰ نمی‌دهد. +- ⚠️ **مرزی (ریست سالانه):** با `reset_policy: yearly`، اولین پروندهٔ سال شمسی بعدی دوباره از `{SEQ}=1` شروع می‌شود و با پروندهٔ سال قبل تداخل ندارد (چون `{YY}` در الگوست). +- ⚠️ **مرزی (الگوی خاموش):** با `enabled: false` رفتار امروز حفظ می‌شود — شماره تولید نمی‌شود و ورود دستی برای همه باز است. +- ⚠️ **مرزی (سرریز شمارنده):** `{SEQ:3}` وقتی شمارنده به ۱۰۰۰ می‌رسد → شماره بدون پدینگ اضافه ادامه پیدا کند (`1000`)، نه اینکه بریده شود یا خطا بدهد. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Patient/Entity/PatientRecord.php` | ستون `record_number` (خط ۴۸) — هدفِ مقداردهی | +| `clinicpro/src/Patient/Controller/PatientController.php` | `POST /api/v1/patient` (خط ~۷۸۴)، `GET /api/v1/patient/{uuid}` (خط ۷۹۹)، `PATCH` (خط ~۹۳۵) | +| `clinicpro/src/Patient/Service/PatientService.php` | ساخت خودکار پرونده از نوبت (خط ~۲۱۱) | +| `clinicpro/src/Insurance/Entity/TenantServiceCategorySetting.php` | **الگوی مرجع** برای یک تنظیم per-tenant | +| `clinicpro/src/Representation/Service/JalaliDateService.php` | تبدیل شمسی (`gregorianToJalali`) برای توکن‌های `{YY}`/`{MM}` | +| `clinicpro/src/Shared/Context/EntityContextResolver.php` | `ownedEntity($user)` — تشخیص «صاحب محیط» برای گیتِ ورود دستی | +| `clinicpro/assets/admin/pages/PatientRecordFormPage.tsx` | فیلد و اعتبارسنجی `record_number` (خطوط ۲۲، ۱۱۹) | +| `clinicpro/assets/admin/components/layout/settingsMenu.ts` | افزودن آیتم تنظیمات | +| `clinicpro/docs/api/patient.md` | مستند endpointها | + +## وضعیت فعلی + +**`src/Patient/Entity/PatientRecord.php:44-49`** — ستون بدون قید یکتایی: + +```php + // Clinic-scoped case-file number. Patient identity/demographics (gender, + // date_of_birth, referral_source, description, insurance, …) live on the + // patient's UserProfile and are set via PATCH /patient/{uuid}. + #[ORM\Column(name: 'record_number', type: 'string', length: 40, nullable: true)] + private ?string $recordNumber = null; +``` + +**`src/Patient/Controller/PatientController.php:784-795`** — ساخت دستی، شماره فقط اگر کاربر فرستاده باشد: + +```php + $record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId); + + if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') { + $record->setRecordNumber($rn); + } + $tagError = $this->applyRecordTags($record, $data, $entityType, $entityId); + if ($tagError !== null) { + return $tagError; + } + + $this->recordRepo->save($record); + + return $this->success($record->toArray(), 201); +``` + +**`src/Patient/Service/PatientService.php:209-213`** — ساخت خودکار، بدون هیچ شماره‌ای: + +```php + $record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient); + if ($record === null) { + $record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById); + $this->recordRepo->save($record); + } +``` + +**`assets/admin/pages/PatientRecordFormPage.tsx:22,119-120`** — فیلد دستیِ الزامی: + +```tsx + record_number: z.string().min(1, 'شماره پرونده الزامی است'), +... + +
+``` + +--- + +## وظایف + +### ۱. Entity + migration تنظیمات الگو + +`src/Patient/Entity/RecordNumberPattern.php` — یک ردیف به ازای هر محیط، دقیقاً با سبک `TenantServiceCategorySetting` (همان `entity_type/entity_id` + unique constraint، timestamp عدد صحیح): + +```php +#[ORM\Entity(repositoryClass: RecordNumberPatternRepository::class)] +#[ORM\Table(name: 'record_number_patterns')] +#[ORM\UniqueConstraint(name: 'uniq_record_number_pattern', columns: ['entity_type', 'entity_id'])] +class RecordNumberPattern +{ + public const RESET_NONE = 'none'; + public const RESET_YEARLY = 'yearly'; + public const RESET_MONTHLY = 'monthly'; + + // entity_type, entity_id, enabled(bool), pattern(string 60), + // reset_policy(string 10), counter(int), counter_period(string 7, nullable), + // updated_at(int) +} +``` + +`counter_period` مهر دورهٔ فعلیِ شمارنده است (`'1405'` برای yearly، `'1405-05'` برای monthly، `null` برای none). با ورود دورهٔ جدید، `counter` صفر و `counter_period` به‌روز می‌شود — بدون این ستون، «آیا سال عوض شده؟» فقط با حدس از `updated_at` قابل جواب بود. + +سپس: + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` سبز باشد؛ جدول با `ddev exec mysql -uroot -proot db -e "DESCRIBE record_number_patterns;"` وجود داشته باشد. + +--- + +### ۲. یکتایی شمارهٔ پرونده در سطح دیتابیس + +قبل از افزودن ایندکس، تداخل‌های موجود را ببین: + +```sql +SELECT entity_type, entity_id, record_number, COUNT(*) c +FROM patient_records WHERE record_number IS NOT NULL +GROUP BY 1,2,3 HAVING c > 1; +``` + +اگر ردیفی برگشت، **به کاربر گزارش بده و متوقف شو** — پاک‌کردن خودسرانهٔ شمارهٔ پروندهٔ واقعی مجاز نیست. اگر خالی بود، unique index روی `(entity_type, entity_id, record_number)` اضافه کن (MariaDB چند `NULL` را در unique می‌پذیرد، پس پرونده‌های بی‌شماره مانع نمی‌شوند). + +این ایندکس **تور ایمنی** است، نه مکانیزم اصلی؛ مکانیزم اصلی قفلِ وظیفهٔ ۳ است. + +**نحوه تست:** درج دستی دو ردیف با شمارهٔ یکسان در یک محیط → خطای دیتابیس. + +--- + +### ۳. سرویس تولید شماره — `RecordNumberGenerator` + +`src/Patient/Service/RecordNumberGenerator.php`، تنها جایی که شماره ساخته می‌شود. سه مسئولیت جدا: + +**الف) رندر الگو (خالص، بدون I/O):** + +توکن‌های مجاز — هر چیز دیگری `422`: + +| توکن | معنی | +|---|---| +| `{YY}` | دو رقم آخر سال شمسی (`05`) | +| `{YYYY}` | سال شمسی کامل (`1405`) | +| `{MM}` | ماه شمسی دو رقمی (`05`) | +| `{SEQ}` / `{SEQ:n}` | شمارنده، با `n` رقم پدینگ صفر (پیش‌فرض ۱) | + +سال/ماه شمسی از `JalaliDateService::gregorianToJalali()` می‌آید — **پیاده‌سازی دستی ننویس**؛ همان فایل توضیح می‌دهد نسخهٔ دست‌ساز قبلی غلط بود. + +**ب) گرفتن شمارهٔ بعدی (تراکنشی):** + +```php +public function next(string $entityType, int $entityId): ?string +{ + // بدون الگو یا با enabled=false → null (رفتار امروز) + return $this->em->wrapInTransaction(function () use ($entityType, $entityId): ?string { + $pattern = $this->repo->lockForUpdate($entityType, $entityId); // SELECT ... FOR UPDATE + if ($pattern === null || !$pattern->isEnabled()) { + return null; + } + $period = $this->periodKey($pattern->getResetPolicy()); + if ($pattern->getCounterPeriod() !== $period) { + $pattern->resetCounter($period); + } + $pattern->incrementCounter(); + + return $this->render($pattern->getPattern(), $pattern->getCounter()); + }); +} +``` + +قفلِ ردیف (`LockMode::PESSIMISTIC_WRITE`) شرطِ معیار پذیرشِ «دو درخواست هم‌زمان» است؛ خواندن-افزایش-نوشتن بدون قفل، دو شمارهٔ یکسان می‌دهد. + +**ج) پیش‌نمایش (بدون افزایش شمارنده):** `preview(string $pattern, string $resetPolicy): string` برای صفحهٔ تنظیمات. + +**نکتهٔ SOLID:** این سرویس با constructor injection به `RecordNumberPatternRepository` و `JalaliDateService` وابسته است؛ `new` ممنوع. Controller و `PatientService` هر دو فقط `next()` را صدا می‌زنند — منطق دو جا کپی نشود. + +**نحوه تست:** unit test روی `render()` برای هر توکن + سرریز `{SEQ:3}` روی ۱۰۰۰؛ و یک functional test که دو بار `next()` را صدا بزند و دو شمارهٔ متوالی بگیرد. + +--- + +### ۴. Endpointهای تنظیمات + +در `src/Patient/Controller/` (کنترلر جدید `RecordNumberSettingsController extends BaseController`): + +| Method | Route | دسترسی | +|---|---|---| +| `GET` | `/api/v1/patient-record-number-settings` | هر کاربرِ محیط با `patients.view` | +| `PUT` | `/api/v1/patient-record-number-settings` | فقط صاحب محیط یا `ROLE_ADMIN` | + +پاسخ `GET`: + +```json +{ "success": true, "data": { + "enabled": true, "pattern": "MD-{YY}-{SEQ:4}", "reset_policy": "yearly", + "counter": 12, "next_preview": "MD-05-0013", "can_edit": true +} } +``` + +`can_edit` را از `EntityContextResolver::ownedEntity($user)` بساز (بررسی کن که این متد واقعاً همان چیزی را می‌دهد که لازم است؛ اگر نه، از `ClinicDoctorAccessChecker`/مالکِ `Clinic::getUser()` استفاده کن). پنل با همین فلگ فرم را read-only می‌کند — ولی **گیت اصلی سمت سرور است**، نه UI. + +اعتبارسنجی `PUT`: `pattern` غیرخالی، حداکثر ۶۰ نویسه، فقط توکن‌های مجاز، حتماً شامل `{SEQ...}`، و `reset_policy` یکی از سه مقدار. هر خطا `422` با `field` درست از `$this->validationError()`/`$this->error()`. + +**نحوه تست:** + +```bash +T=$(ddev exec php bin/console lexik:jwt:generate-token -c 'App\\Auth\\Entity\\User' -- 09390039833) +curl -sk -X PUT https://clinic-pro.ddev.site/api/v1/patient-record-number-settings \ + -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \ + -d '{"enabled":true,"pattern":"MD-{YY}-{SEQ:4}","reset_policy":"yearly"}' +# سپس همان با توکن منشی 09390039875 → باید 403 بدهد +``` + +--- + +### ۵. اتصال به هر دو مسیر ساخت پرونده + +**`PatientController::create`** — منطق فعلیِ خط ۷۸۶ عوض می‌شود: + +```php +$manual = trim((string) ($data['record_number'] ?? '')); +if ($manual !== '' && !$this->canSetRecordNumberManually($user, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'ثبت دستی شماره پرونده مجاز نیست', 403, 'record_number'); +} +$record->setRecordNumber($manual !== '' ? $manual : $this->recordNumbers->next($entityType, $entityId)); +``` + +**`PatientService`** (خط ~۲۱۱) — همان یک خط: + +```php +$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById); +$record->setRecordNumber($this->recordNumbers->next($entityType, $entityId)); +$this->recordRepo->save($record); +``` + +بدون این دومی، خواستهٔ «هر پرونده‌ای که ثبت می‌شود» برآورده نمی‌شود — بیشترِ پرونده‌ها از همین مسیرِ نوبت ساخته می‌شوند. + +**نحوه تست:** با الگوی فعال یک `POST /api/v1/patient` بدون `record_number` بزن و شماره را ببین؛ سپس یک نوبت را قطعی کن (`POST /api/v1/appointment/{uuid}/confirm`) و شمارهٔ پروندهٔ ساخته‌شده را از `GET /api/v1/patients?search=...` بخوان. + +--- + +### ۶. Backfill تنبل هنگام باز شدن پرونده + +در `PatientController::show` (خط ۷۹۹)، **قبل از** ساخت پاسخ: + +```php +if ($record->getRecordNumber() === null) { + $number = $this->recordNumbers->next($entityType, $entityId); + if ($number !== null) { + $record->setRecordNumber($number); + $this->recordRepo->save($record); + } +} +``` + +سه نکته که باید رعایت شود: + +- فقط وقتی الگو فعال است (`next()` خودش `null` برمی‌گرداند) — وگرنه یک `GET` ساده تبدیل به نوشتن بی‌دلیل می‌شود. +- idempotent: بار دوم چون شماره پر است، هیچ نوشتنی رخ نمی‌دهد. (معیار پذیرشِ «GET دوم همان شماره») +- دو `GET` هم‌زمان روی یک پرونده: قفلِ وظیفهٔ ۳ دو شمارهٔ متفاوت می‌دهد ولی آخرین نوشتن برنده است و یک شماره هدر می‌رود. قابل قبول است؛ **در کد کامنت شود** که چرا (هدررفتِ یک شماره در برابر پیچیدگی قفل روی خودِ رکورد). + +**نحوه تست:** یک `patient_records` با `record_number = NULL` در DB پیدا/بساز، دو بار `GET /api/v1/patient/{uuid}` بزن، هر دو بار شمارهٔ یکسان برگردد. + +--- + +### ۷. پنل ادمین — صفحهٔ تنظیمات + فرم پرونده + +**الف) صفحهٔ تنظیمات** `assets/admin/pages/RecordNumberSettingsPage.tsx`: + +- داخل `SettingsLayout` مثل بقیهٔ صفحات تنظیمات. +- سوییچ «شماره‌گذاری خودکار پرونده»، ورودی الگو، `SearchableSelect` برای سیاست ریست (**نه** `
- -
+ + {manualAllowed ? ( +
+ +
+ ) : ( +
+ +
+ )} + {autoNumbered && ( + + {manualAllowed + ? `خالی بماند تا از الگو ساخته شود (شمارهٔ بعدی: ${settings?.next_preview ?? '—'})` + : 'شماره از الگوی مجموعه ساخته می‌شود'} + + )}
form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })} diff --git a/assets/admin/pages/RecordNumberSettingsPage.test.tsx b/assets/admin/pages/RecordNumberSettingsPage.test.tsx new file mode 100644 index 00000000..10ebc237 --- /dev/null +++ b/assets/admin/pages/RecordNumberSettingsPage.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, 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 {}, +})); + +import { api } from '../lib/api'; +import RecordNumberSettingsPage from './RecordNumberSettingsPage'; + +const get = api.get as ReturnType; +const put = api.put as ReturnType; + +function mockSettings(overrides: Record = {}) { + get.mockResolvedValue({ + success: true, + data: { + enabled: false, pattern: '{YY}-{SEQ:4}', reset_policy: 'none', + counter: 0, next_preview: '05-0001', can_edit: true, ...overrides, + }, + }); +} + +beforeEach(() => { + get.mockReset(); + put.mockReset(); + put.mockResolvedValue({ success: true, data: {} }); +}); + +describe('RecordNumberSettingsPage', () => { + it('الگو و پیش‌نمایش سرور را نشان می‌دهد', async () => { + mockSettings({ enabled: true, pattern: 'MD-{YY}-{SEQ:4}', reset_policy: 'yearly', counter: 12, next_preview: 'MD-05-0013' }); + renderWithProviders(); + + expect(await screen.findByDisplayValue('MD-{YY}-{SEQ:4}')).toBeInTheDocument(); + expect(screen.getByText('MD-05-0013')).toBeInTheDocument(); + expect(screen.getByText(/12 پرونده را شماره‌گذاری کرده/)).toBeInTheDocument(); + }); + + /** ✅ موفق: تغییر الگو و ذخیره با همان سه فیلد. */ + it('الگوی تغییریافته را ذخیره می‌کند', async () => { + mockSettings(); + renderWithProviders(); + + const input = await screen.findByLabelText('الگوی شماره'); + fireEvent.change(input, { target: { value: 'P-{SEQ:3}' } }); + fireEvent.click(screen.getByRole('switch', { name: 'شماره‌گذاری خودکار پرونده' })); + + fireEvent.click(screen.getByText('ذخیره تنظیمات')); + await waitFor(() => expect(put).toHaveBeenCalled()); + + expect(put.mock.calls[0][0]).toBe('/api/v1/patient-record-number-settings'); + expect(put.mock.calls[0][1]).toEqual({ enabled: true, pattern: 'P-{SEQ:3}', reset_policy: 'none' }); + }); + + /** ⚠️ مرزی: بدون تغییر، دکمهٔ ذخیره غیرفعال است. */ + it('تا تغییری نباشد دکمهٔ ذخیره غیرفعال است', async () => { + mockSettings(); + renderWithProviders(); + + const btn = await screen.findByText('ذخیره تنظیمات'); + expect(btn).toBeDisabled(); + }); + + /** ❌ خطا: کاربری که صاحب مجموعه نیست فقط می‌بیند. */ + it('بدون can_edit فرم فقط خواندنی است', async () => { + mockSettings({ can_edit: false, enabled: true }); + renderWithProviders(); + + expect(await screen.findByText(/تغییر الگو فقط با حساب صاحب مجموعه/)).toBeInTheDocument(); + expect(screen.getByLabelText('الگوی شماره')).toHaveAttribute('readonly'); + expect(screen.queryByText('ذخیره تنظیمات')).toBeNull(); + }); +}); diff --git a/assets/admin/pages/RecordNumberSettingsPage.tsx b/assets/admin/pages/RecordNumberSettingsPage.tsx new file mode 100644 index 00000000..82bf0e39 --- /dev/null +++ b/assets/admin/pages/RecordNumberSettingsPage.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import SettingsLayout from '../components/layout/SettingsLayout'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings'; +import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings'; + +const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [ + { value: 'none', label: 'هرگز — شمارنده پیوسته جلو می‌رود' }, + { value: 'yearly', label: 'هر سال شمسی از ۱ شروع شود' }, + { value: 'monthly', label: 'هر ماه شمسی از ۱ شروع شود' }, +]; + +const TOKENS: [string, string][] = [ + ['{SEQ}', 'شمارنده — {SEQ:4} یعنی چهار رقمی با صفرِ ابتدایی'], + ['{YY}', 'دو رقم آخر سال شمسی'], + ['{YYYY}', 'سال شمسی کامل'], + ['{MM}', 'ماه شمسی دو رقمی'], +]; + +/** + * شمارهٔ پرونده — الگوی شماره‌گذاری خودکار پرونده‌های همین مجموعه. + * + * پیش‌نمایش سمت کلاینت ساخته نمی‌شود: همان `next_preview` سرور نشان داده می‌شود تا + * دو پیاده‌سازیِ رندر (اینجا و در `RecordNumberGenerator`) از هم واگرا نشوند. + */ +export default function RecordNumberSettingsPage() { + const { settings, loading, save } = useRecordNumberSettings(); + + const [enabled, setEnabled] = useState(false); + const [pattern, setPattern] = useState(''); + const [resetPolicy, setResetPolicy] = useState('none'); + + useEffect(() => { + if (!settings) return; + setEnabled(settings.enabled); + setPattern(settings.pattern); + setResetPolicy(settings.reset_policy); + }, [settings]); + + const readOnly = !settings?.can_edit; + const dirty = !!settings && ( + enabled !== settings.enabled || pattern !== settings.pattern || resetPolicy !== settings.reset_policy + ); + + return ( + +
+

شماره پرونده

+

+ با روشن‌کردن این گزینه، شمارهٔ هر پروندهٔ تازه — چه از فرم تشکیل پرونده و چه از + نوبتی که قطعی می‌شود — بر اساس همین الگو ساخته می‌شود. +

+ + {loading ? ( +
{Array.from({ length: 3 }).map((_, i) =>
)}
+ ) : !settings ? ( +
+ دسترسی به تنظیمات شمارهٔ پرونده ندارید. +
+ ) : ( + <> + {readOnly && ( +
+ تغییر الگو فقط با حساب صاحب مجموعه ممکن است؛ اینجا فقط مقدار فعلی را می‌بینید. +
+ )} + + + +
+ +
+ setPattern(e.target.value)} + placeholder="MD-{YY}-{SEQ:4}" + /> +
+ +
    + {TOKENS.map(([token, hint]) => ( +
  • + {token} — {hint} +
  • + ))} +
+
+ +
+ +
+ setResetPolicy((v ?? 'none') as RecordNumberResetPolicy)} + ariaLabelledBy="reset-policy-label" + isDisabled={readOnly} + height={44} + /> +
+
+ +
+ شمارهٔ بعدی: + {settings.next_preview} +
+ {dirty + ? 'پیش‌نمایش پس از ذخیره به‌روز می‌شود.' + : `شمارنده تا اینجا ${settings.counter} پرونده را شماره‌گذاری کرده است.`} +
+
+ + {!readOnly && ( +
+ +
+ )} + + )} +
+ + ); +} diff --git a/docs/api/patient.md b/docs/api/patient.md index 8298cf30..cf892a90 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -117,6 +117,69 @@ restriction. --- +### الگوی شمارهٔ پرونده (2026-08) + +شمارهٔ پرونده می‌تواند به‌جای ورود دستی، از یک الگوی per-tenant تولید شود. الگو و +شمارنده‌اش روی `record_number_patterns` می‌نشینند — یک ردیف به ازای هر محیط. + +#### `GET /api/v1/patient-record-number-settings` + +**دسترسی:** `patients.view` (منشی/پزشکِ عضو با همان مجوز). محیط از `UserActiveContext` +حل می‌شود؛ محیطِ حل‌نشده → `403 ERR_FORBIDDEN_001`. + +خروجی واقعی (محیطی که هنوز چیزی ذخیره نکرده — همه‌چیز پیش‌فرض): + +```json +{"success":true,"data":{"enabled":false,"pattern":"{YY}-{SEQ:4}","reset_policy":"none","counter":0,"next_preview":"05-0001","can_edit":true}} +``` + +| فیلد | معنی | +|---|---| +| `enabled` | تولید خودکار روشن است یا نه. خاموش = رفتار قدیمی (ورود دستی برای همه) | +| `pattern` | الگو با توکن‌های `{YYYY}` `{YY}` `{MM}` `{SEQ}` `{SEQ:n}` | +| `reset_policy` | `none` \| `yearly` \| `monthly` — بر مبنای تقویم **شمسی** | +| `counter` | شمارندهٔ فعلیِ همین دوره | +| `next_preview` | شمارهٔ بعدی، بدون مصرف‌کردن شمارنده | +| `can_edit` | آیا همین کاربر اجازهٔ `PUT` دارد (صاحب محیط یا ادمین) | + +#### `PUT /api/v1/patient-record-number-settings` + +**دسترسی:** فقط **صاحب محیط** (مالک کلینیک، یا پزشک در مطب شخصی خودش) و `ROLE_ADMIN`. +پزشکِ مهمانِ کلینیک و منشی — حتی با `patients.update` — رد می‌شوند: شمارهٔ پرونده +قرارداد ثبتِ کل مجموعه است. + +| فیلد | نوع | الزامی | قاعده | +|---|---|---|---| +| `enabled` | bool | — | پیش‌فرض `false` | +| `pattern` | string | ✅ | حداکثر ۶۰ نویسه، فقط توکن‌های مجاز، حتماً شامل `{SEQ...}` | +| `reset_policy` | string | — | `none` \| `yearly` \| `monthly`، پیش‌فرض `none` | + +خروجی واقعی: + +```json +{"success":true,"data":{"enabled":true,"pattern":"MD-{YY}-{SEQ:4}","reset_policy":"yearly","counter":0,"next_preview":"MD-05-0001","can_edit":true}} +``` + +**Errors** (پیام‌ها واقعی‌اند — از اجرای همین اندپوینت): + +| Code | HTTP | field | شرط | +|------|------|-------|-----| +| `ERR_FORBIDDEN_001` | 403 | — | «تغییر الگوی شماره پرونده فقط با حساب صاحب مجموعه ممکن است» | +| `ERR_VALIDATION_002` | 422 | `pattern` | «توکن ناشناخته: {FOO} — مجاز: {YYYY}، {YY}، {MM}، {SEQ} یا {SEQ:n}» | +| `ERR_VALIDATION_002` | 422 | `pattern` | «الگو باید {SEQ} یا {SEQ:n} داشته باشد، وگرنه همهٔ پرونده‌ها یک شماره می‌گیرند» | +| `ERR_VALIDATION_002` | 422 | `pattern` | «وقتی سال در الگو هست، ریست شمارنده باید سالانه یا ماهانه باشد» | +| `ERR_VALIDATION_002` | 422 | `pattern` | «وقتی {MM} در الگو هست، ریست شمارنده باید ماهانه باشد» | +| `ERR_VALIDATION_002` | 422 | `reset_policy` | «سیاست ریست شمارنده نامعتبر است» | +| `ERR_VALIDATION_001` | 422 | — | بدنهٔ غیر-JSON | + +- **تغییر الگو شمارنده را صفر نمی‌کند.** شماره‌های صادرشده وجود دارند و شروع دوبارهٔ + دنباله مستقیم به `uniq_patient_record_number` می‌خورد. صفرشدن فقط با ورود به دورهٔ + تازه (سال/ماه شمسی) رخ می‌دهد. +- **الگوی خاموش هم اعتبارسنجی می‌شود** تا خطای الگو سرِ فرمِ تنظیمات دیده شود، نه + روزی که کاربر روشنش می‌کند و ساختِ پرونده می‌شکند. + +--- + ### Create Patient Record ``` @@ -138,13 +201,24 @@ Creates a patient record for a user under the current entity. If the record alre "mobile": "09xxxxxxxxx (اختیاری — برای جستجو یا ساخت بیمار جدید)", "name": "string (الزامی فقط هنگام ساخت بیمار جدید)", "national_code": "string (اختیاری، ۱۰ رقم)", - "record_number": "string (اختیاری) — شماره پرونده، مخصوص رکورد", + "record_number": "string (اختیاری) — شماره پرونده، مخصوص رکورد. با الگوی فعال فقط از صاحب مجموعه پذیرفته می‌شود", "tags": ["uuid برچسب‌های TenantTag (اختیاری) — باید متعلق به همین tenant باشند"] } ``` - اگر `user_uuid` و `mobile` هر دو خالی باشند → خطا. - `record_number` و `tags` روی خودِ رکورد ذخیره می‌شوند (نه پروفایل کاربر). سایر مشخصات دموگرافیک (`gender`, `date_of_birth`, `referral_source`, `description`, بیمه‌ها) روی `UserProfile` هستند و از طریق `PATCH /patient/{uuid}` ست می‌شوند. پاسخ همیشه `record_number` و `tags: [{uuid,name,color}]` را برمی‌گرداند. +- **شمارهٔ پرونده با الگوی فعال (2026-08):** اگر محیط الگوی فعال داشته باشد و `record_number` + فرستاده **نشود**، سرور شمارهٔ بعدیِ همان الگو را می‌سازد و در پاسخ برمی‌گرداند + (مثلاً `"record_number": "MD-0001"`). فرستادنِ `record_number` توسط کسی جز صاحب + مجموعه → `403 ERR_FORBIDDEN_001` با `field: record_number` و پیام «ثبت دستی شماره + پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته می‌شود». بدون الگوی فعال، رفتار قبلی + برقرار است: شماره دستی است و همه می‌توانند بفرستند. همین قاعده روی + `PATCH /patient/{uuid}` هم اعمال می‌شود. تنظیم الگو: + [الگوی شمارهٔ پرونده](#الگوی-شمارهٔ-پرونده-2026-08). +- **پروندهٔ خودکارِ نوبت هم شماره می‌گیرد:** پرونده‌ای که هنگام قطعی‌شدن نوبت ساخته + می‌شود (`PatientService::autoCreateOnAppointmentConfirm`) از همان دنباله شماره + می‌گیرد؛ پیش از این همیشه `null` بود. - برچسب متعلق به tenant دیگر → `422 ERR_VALIDATION_001` (`field: tags`). - `national_code` فقط وقتی روی کاربر ست می‌شود که کاربر کد ملی نداشته باشد. - موبایل تکراری duplicate نمی‌سازد؛ همان کاربر استفاده می‌شود. @@ -185,6 +259,11 @@ GET /api/v1/patient/{uuid} Returns a single patient record, enriched with the patient's full profile (`profile`) درون‌خطی از `UserProfile`. اگر پروفایل وجود نداشت، فیلدها `null` برمی‌گردند (نه خطا). نام بیمه‌ها از روی id resolve می‌شوند. +> **اثر جانبیِ عمدی (2026-08):** اگر این پرونده `record_number = null` باشد و محیط الگوی +> فعال داشته باشد، همین `GET` شماره را تخصیص می‌دهد و ذخیره می‌کند — یعنی پرونده‌های +> ساخته‌شده پیش از الگو، اولین بار که باز می‌شوند شماره‌دار می‌شوند. فراخوانی دوم چیزی +> نمی‌نویسد و همان شماره را برمی‌گرداند. بدون الگوی فعال، این `GET` فقط می‌خواند. + **Response 200:** ```json diff --git a/migrations/Version20260804082035.php b/migrations/Version20260804082035.php new file mode 100644 index 00000000..25cadf82 --- /dev/null +++ b/migrations/Version20260804082035.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + CREATE TABLE record_number_patterns ( + id INT AUTO_INCREMENT NOT NULL, + entity_type VARCHAR(10) NOT NULL, + entity_id INT NOT NULL, + enabled TINYINT DEFAULT 0 NOT NULL, + pattern VARCHAR(60) NOT NULL, + reset_policy VARCHAR(10) DEFAULT 'none' NOT NULL, + counter INT DEFAULT 0 NOT NULL, + counter_period VARCHAR(7) DEFAULT NULL, + updated_at INT NOT NULL, + UNIQUE INDEX uniq_record_number_pattern (entity_type, entity_id), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE record_number_patterns'); + } +} diff --git a/migrations/Version20260804082400.php b/migrations/Version20260804082400.php new file mode 100644 index 00000000..c73de45b --- /dev/null +++ b/migrations/Version20260804082400.php @@ -0,0 +1,33 @@ +addSql('CREATE UNIQUE INDEX uniq_patient_record_number ON patient_records (entity_type, entity_id, record_number)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX uniq_patient_record_number ON patient_records'); + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 21161994..8520b96b 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -57,6 +57,9 @@ class PatientController extends BaseController private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo, private readonly SecretaryAccessChecker $secretaryAccess, private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess, + private readonly \App\Patient\Service\RecordNumberGenerator $recordNumbers, + private readonly \App\Patient\Repository\RecordNumberPatternRepository $recordNumberPatterns, + private readonly \App\Shared\Context\EntityContextResolver $contextResolver, ) {} // ── Financials (مالی: پرداخت / تراکنش / کیف‌پول) ──────────────────────────── @@ -783,9 +786,17 @@ class PatientController extends BaseController $record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId); - if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') { - $record->setRecordNumber($rn); + // شمارهٔ دستی فقط از صاحب مجموعه پذیرفته می‌شود؛ بقیه شمارهٔ الگو را می‌گیرند. + // در نبودِ الگوی فعال، `next()` مقدار null می‌دهد و رفتار قدیمی (ورود دستی + // برای همه) دست‌نخورده می‌ماند. + $manualNumber = trim((string) ($data['record_number'] ?? '')); + if ($manualNumber !== '' && !$this->canSetRecordNumberManually($user, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'ثبت دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته می‌شود', 403, 'record_number'); } + + $record->setRecordNumber( + $manualNumber !== '' ? $manualNumber : $this->recordNumbers->next($entityType, $entityId), + ); $tagError = $this->applyRecordTags($record, $data, $entityType, $entityId); if ($tagError !== null) { return $tagError; @@ -807,12 +818,39 @@ class PatientController extends BaseController return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); } + $this->backfillRecordNumber($record, $entityType, $entityId); + $data = $record->toArray(); $data['profile'] = $this->buildPatientProfile($record->getUser()); return $this->success($data); } + /** + * پرونده‌های ساخته‌شده پیش از الگو، اولین بار که باز می‌شوند شماره می‌گیرند. + * + * اثر جانبیِ عمدی روی یک `GET` است: جایگزینش یا backfill دسته‌ای بود (که ترتیب + * شماره‌ها را به ترتیب `id` گره می‌زد) یا رها کردن پرونده‌های قدیمی بی‌شماره. + * بار دوم چون شماره پر است هیچ نوشتنی رخ نمی‌دهد، و بدون الگوی فعال `next()` + * مقدار `null` می‌دهد و این متد بی‌اثر است. + * + * دو `GET` هم‌زمان روی یک پروندهٔ بی‌شماره دو شماره می‌گیرند و آخری برنده است؛ + * یعنی نهایتاً یک شماره هدر می‌رود. قفل‌کردنِ خودِ رکورد برای همین، هزینه‌اش از + * فایده‌اش بیشتر بود. + */ + private function backfillRecordNumber(PatientRecord $record, string $entityType, ?int $entityId): void + { + if ($record->getRecordNumber() !== null || $entityId === null) { + return; + } + + $number = $this->recordNumbers->next($entityType, $entityId); + if ($number !== null) { + $record->setRecordNumber($number); + $this->recordRepo->save($record); + } + } + #[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { @@ -933,6 +971,9 @@ class PatientController extends BaseController $this->profileRepo->save($profile); if (array_key_exists('record_number', $data)) { + if (!$this->canSetRecordNumberManually($user, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'تغییر دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته می‌شود', 403, 'record_number'); + } $rn = trim((string) ($data['record_number'] ?? '')); $record->setRecordNumber($rn === '' ? null : $rn); } @@ -1252,6 +1293,23 @@ class PatientController extends BaseController return $this->scope($user)->toLegacyTuple(); } + /** + * ورود دستیِ شمارهٔ پرونده. + * + * تا وقتی الگویی فعال نشده، همه‌چیز مثل قبل است و شماره دستی وارد می‌شود. با فعال + * شدن الگو، شماره قراردادِ کلِ مجموعه می‌شود و فقط صاحبش می‌تواند خارج از دنباله + * شماره بگذارد (مثلاً پروندهٔ کاغذیِ قدیمی). + */ + private function canSetRecordNumberManually(User $user, string $entityType, ?int $entityId): bool + { + $pattern = $this->recordNumberPatterns->findForPair($entityType, (int) $entityId); + if ($pattern === null || !$pattern->isEnabled()) { + return true; + } + + return $this->contextResolver->owns($user, $entityType, $entityId); + } + private function assertPatientGate(string $entityType, ?int $entityId): void { if ($entityId === null) { diff --git a/src/Patient/Controller/RecordNumberSettingsController.php b/src/Patient/Controller/RecordNumberSettingsController.php new file mode 100644 index 00000000..a6021b65 --- /dev/null +++ b/src/Patient/Controller/RecordNumberSettingsController.php @@ -0,0 +1,140 @@ + []]], + responses: [new OA\Response(response: 200, description: 'تنظیمات الگو')], + )] + #[Route('/api/v1/patient-record-number-settings', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function show(#[CurrentUser] User $user): JsonResponse + { + $this->secretaryAccess->denyUnlessGranted($user, 'patients', 'view'); + $this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'view'); + + [$entityType, $entityId] = $this->requireEnvironment($user); + $pattern = $this->patternRepo->findForPair($entityType, $entityId); + + return $this->success($this->present($pattern, $user, $entityType, $entityId)); + } + + #[OA\Put( + path: '/api/v1/patient-record-number-settings', + summary: 'ثبت الگوی شمارهٔ پرونده — فقط صاحب محیط', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response(response: 200, description: 'ذخیره شد'), + new OA\Response(response: 403, description: 'فقط صاحب محیط'), + new OA\Response(response: 422, description: 'الگوی نامعتبر'), + ], + )] + #[Route('/api/v1/patient-record-number-settings', methods: ['PUT'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function update(Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->requireEnvironment($user); + + if (!$this->contextResolver->owns($user, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'تغییر الگوی شماره پرونده فقط با حساب صاحب مجموعه ممکن است', 403); + } + + $data = json_decode($request->getContent(), true); + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $resetPolicy = (string) ($data['reset_policy'] ?? RecordNumberPattern::RESET_NONE); + if (!in_array($resetPolicy, RecordNumberPattern::RESET_POLICIES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سیاست ریست شمارنده نامعتبر است', 422, 'reset_policy'); + } + + $patternText = trim((string) ($data['pattern'] ?? '')); + $enabled = (bool) ($data['enabled'] ?? false); + + // الگوی خاموش هم اعتبارسنجی می‌شود: ذخیرهٔ الگوی خرابِ خاموش یعنی روزی که + // کاربر روشنش می‌کند، خطا سرِ ساختِ پرونده بیرون می‌زند نه سرِ فرم تنظیمات. + $errors = $this->generator->validatePattern($patternText, $resetPolicy); + if ($errors !== []) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, implode(' · ', $errors), 422, 'pattern'); + } + + $pattern = $this->patternRepo->findForPair($entityType, $entityId) + ?? new RecordNumberPattern($entityType, $entityId); + + // تغییر الگو شمارنده را صفر نمی‌کند: شماره‌های صادرشده وجود دارند و شروع دوبارهٔ + // دنباله، مستقیم به تداخل با آن‌ها می‌خورد. + $pattern->setPattern($patternText)->setResetPolicy($resetPolicy)->setEnabled($enabled); + $this->patternRepo->save($pattern); + + return $this->success($this->present($pattern, $user, $entityType, $entityId)); + } + + /** @return array */ + private function present(?RecordNumberPattern $pattern, User $user, string $entityType, int $entityId): array + { + $patternText = $pattern?->getPattern() ?? RecordNumberPattern::DEFAULT_PATTERN; + $counter = $pattern?->getCounter() ?? 0; + + return [ + 'enabled' => $pattern?->isEnabled() ?? false, + 'pattern' => $patternText, + 'reset_policy' => $pattern?->getResetPolicy() ?? RecordNumberPattern::RESET_NONE, + 'counter' => $counter, + 'next_preview' => $this->generator->preview($patternText, $counter), + 'can_edit' => $this->contextResolver->owns($user, $entityType, $entityId), + ]; + } + + /** + * @return array{0: string, 1: int} + * @throws \App\Shared\Exception\AppException وقتی محیط حل نشده (۴۰۳) + */ + private function requireEnvironment(User $user): array + { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { + throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403); + } + + [$type, $id] = $context->toEntityPair(); + + return [$type, (int) $id]; + } +} diff --git a/src/Patient/Entity/PatientRecord.php b/src/Patient/Entity/PatientRecord.php index df42b45b..5115f44a 100644 --- a/src/Patient/Entity/PatientRecord.php +++ b/src/Patient/Entity/PatientRecord.php @@ -13,6 +13,9 @@ use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: PatientRecordRepository::class)] #[ORM\Table(name: 'patient_records')] #[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])] +// شمارهٔ پرونده در هر محیط یکتاست. این تور ایمنیِ شمارندهٔ RecordNumberPattern است، +// نه مکانیزم اصلی؛ چند `NULL` را MariaDB می‌پذیرد، پس پرونده‌های بی‌شماره مانع نیستند. +#[ORM\UniqueConstraint(name: 'uniq_patient_record_number', columns: ['entity_type', 'entity_id', 'record_number'])] class PatientRecord { #[ORM\Id] diff --git a/src/Patient/Entity/RecordNumberPattern.php b/src/Patient/Entity/RecordNumberPattern.php new file mode 100644 index 00000000..b0cb4cad --- /dev/null +++ b/src/Patient/Entity/RecordNumberPattern.php @@ -0,0 +1,118 @@ + false])] + private bool $enabled = false; + + #[ORM\Column(type: 'string', length: 60)] + private string $pattern = self::DEFAULT_PATTERN; + + #[ORM\Column(name: 'reset_policy', type: 'string', length: 10, options: ['default' => self::RESET_NONE])] + private string $resetPolicy = self::RESET_NONE; + + #[ORM\Column(type: 'integer', options: ['default' => 0])] + private int $counter = 0; + + #[ORM\Column(name: 'counter_period', type: 'string', length: 7, nullable: true)] + private ?string $counterPeriod = null; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $entityType, int $entityId) + { + $this->entityType = $entityType; + $this->entityId = $entityId; + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + public function isEnabled(): bool { return $this->enabled; } + public function getPattern(): string { return $this->pattern; } + public function getResetPolicy(): string { return $this->resetPolicy; } + public function getCounter(): int { return $this->counter; } + public function getCounterPeriod(): ?string { return $this->counterPeriod; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + public function setEnabled(bool $v): self { $this->enabled = $v; $this->touch(); return $this; } + public function setPattern(string $v): self { $this->pattern = $v; $this->touch(); return $this; } + + public function setResetPolicy(string $v): self + { + if (!in_array($v, self::RESET_POLICIES, true)) { + throw new \InvalidArgumentException(sprintf('Unknown reset policy "%s".', $v)); + } + $this->resetPolicy = $v; + $this->touch(); + + return $this; + } + + /** ورود به دورهٔ تازه: شمارنده از صفر شروع می‌شود. */ + public function resetCounter(?string $period): self + { + $this->counter = 0; + $this->counterPeriod = $period; + $this->touch(); + + return $this; + } + + public function incrementCounter(): int + { + $this->counter++; + $this->touch(); + + return $this->counter; + } + + private function touch(): void + { + $this->updatedAt = time(); + } +} diff --git a/src/Patient/Repository/RecordNumberPatternRepository.php b/src/Patient/Repository/RecordNumberPatternRepository.php new file mode 100644 index 00000000..0f08ff09 --- /dev/null +++ b/src/Patient/Repository/RecordNumberPatternRepository.php @@ -0,0 +1,50 @@ + + */ +class RecordNumberPatternRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, RecordNumberPattern::class); + } + + public function findForPair(string $entityType, int $entityId): ?RecordNumberPattern + { + return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]); + } + + /** + * همان ردیف، ولی با قفلِ نوشتن (`SELECT … FOR UPDATE`). + * + * شمارندهٔ بدون قفل، دو درخواستِ هم‌زمان را به یک شماره می‌رساند: هر دو مقدار + * قدیمی را می‌خوانند و همان را +۱ می‌کنند. فقط داخل تراکنش صدا زده می‌شود. + */ + public function lockForUpdate(string $entityType, int $entityId): ?RecordNumberPattern + { + return $this->createQueryBuilder('p') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->getQuery() + ->setLockMode(LockMode::PESSIMISTIC_WRITE) + ->getOneOrNullResult(); + } + + public function save(RecordNumberPattern $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index 6ea32927..1670bbbc 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -26,6 +26,7 @@ use App\Patient\Repository\PatientSessionRepository; use App\Patient\Repository\SessionConsumableRepository; use App\Patient\Repository\SessionPaymentRepository; use App\Patient\Repository\SessionServiceRepository; +use App\Patient\Service\RecordNumberGenerator; use App\Settlement\Service\WalletService; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; @@ -58,6 +59,7 @@ class PatientService private readonly \App\Discount\Service\DiscountEngine $discountEngine, private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo, private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, + private readonly RecordNumberGenerator $recordNumbers, private readonly LoggerInterface $logger, ) {} @@ -209,6 +211,9 @@ class PatientService $record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient); if ($record === null) { $record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById); + // پروندهٔ خودکار هم باید در همان دنبالهٔ شماره‌ها بنشیند؛ بیشترِ پرونده‌ها + // از همین مسیر ساخته می‌شوند، نه از فرم پنل. + $record->setRecordNumber($this->recordNumbers->next($entityType, $entityId)); $this->recordRepo->save($record); } diff --git a/src/Patient/Service/RecordNumberGenerator.php b/src/Patient/Service/RecordNumberGenerator.php new file mode 100644 index 00000000..93e22c1b --- /dev/null +++ b/src/Patient/Service/RecordNumberGenerator.php @@ -0,0 +1,149 @@ +em->wrapInTransaction(function () use ($entityType, $entityId): ?string { + $pattern = $this->repo->lockForUpdate($entityType, $entityId); + if ($pattern === null || !$pattern->isEnabled()) { + return null; + } + + $period = $this->periodKey($pattern->getResetPolicy()); + if ($pattern->getCounterPeriod() !== $period) { + $pattern->resetCounter($period); + } + + return $this->render($pattern->getPattern(), $pattern->incrementCounter()); + }); + } + + /** شمارهٔ بعدی بدون مصرف‌کردنش — برای فرمِ تنظیمات. */ + public function preview(string $pattern, int $currentCounter = 0): string + { + return $this->render($pattern, $currentCounter + 1); + } + + /** + * جایگذاری توکن‌ها. تاریخ شمسی از {@see JalaliDateService} می‌آید، نه از محاسبهٔ + * دستی — همان فایل توضیح می‌دهد نسخهٔ دست‌سازِ قبلی ۱۶۰۱ سال خطا داشت. + */ + public function render(string $pattern, int $counter): string + { + [$jy, $jm] = $this->today(); + + return preg_replace_callback( + self::TOKEN_RE, + static function (array $m) use ($jy, $jm, $counter): string { + $token = $m[1]; + + if ($token === 'YYYY') return (string) $jy; + if ($token === 'YY') return substr((string) $jy, -2); + if ($token === 'MM') return str_pad((string) $jm, 2, '0', STR_PAD_LEFT); + + // {SEQ} یا {SEQ:n} — پدینگ سقف نیست: شمارندهٔ بلندتر از n بریده نمی‌شود. + $width = isset($m[2]) && $m[2] !== '' ? (int) $m[2] : 1; + + return str_pad((string) $counter, $width, '0', STR_PAD_LEFT); + }, + $pattern, + ) ?? $pattern; + } + + /** + * خطاهای الگو به‌صورت فهرست، تا فرم همه را یک‌جا نشان دهد. + * + * @return list خالی یعنی معتبر + */ + public function validatePattern(string $pattern, string $resetPolicy): array + { + $errors = []; + $trimmed = trim($pattern); + + if ($trimmed === '') { + return ['الگوی شماره پرونده نمی‌تواند خالی باشد']; + } + if (mb_strlen($trimmed) > 60) { + $errors[] = 'الگو نمی‌تواند بیش از ۶۰ نویسه باشد'; + } + + // هر `{...}`ی که با توکن‌های مجاز جور نباشد، یعنی توکن ناشناخته. + $unknown = preg_replace(self::TOKEN_RE, '', $trimmed); + if (preg_match('/\{[^}]*\}?/', (string) $unknown, $m) === 1) { + $errors[] = sprintf('توکن ناشناخته: %s — مجاز: {YYYY}، {YY}، {MM}، {SEQ} یا {SEQ:n}', $m[0]); + } + + if (!str_contains($trimmed, '{SEQ')) { + $errors[] = 'الگو باید {SEQ} یا {SEQ:n} داشته باشد، وگرنه همهٔ پرونده‌ها یک شماره می‌گیرند'; + } + + // {MM} بدون ریست ماهانه: پیشوندِ ماه عوض می‌شود ولی شمارنده ادامه پیدا می‌کند — + // نتیجه ظاهراً منظم است و در واقع ترتیبِ ماه را نمی‌رساند. + if (str_contains($trimmed, '{MM}') && $resetPolicy !== RecordNumberPattern::RESET_MONTHLY) { + $errors[] = 'وقتی {MM} در الگو هست، ریست شمارنده باید ماهانه باشد'; + } + if (str_contains($trimmed, '{YY}') || str_contains($trimmed, '{YYYY}')) { + if ($resetPolicy === RecordNumberPattern::RESET_NONE) { + $errors[] = 'وقتی سال در الگو هست، ریست شمارنده باید سالانه یا ماهانه باشد'; + } + } + + return $errors; + } + + /** مهرِ دورهٔ فعلی: `1405` سالانه، `1405-05` ماهانه، `null` بدون ریست. */ + private function periodKey(string $resetPolicy): ?string + { + [$jy, $jm] = $this->today(); + + return match ($resetPolicy) { + RecordNumberPattern::RESET_YEARLY => (string) $jy, + RecordNumberPattern::RESET_MONTHLY => sprintf('%04d-%02d', $jy, $jm), + default => null, + }; + } + + /** @return array{0:int,1:int} [سال، ماه] شمسیِ امروز به وقت تهران */ + private function today(): array + { + $now = new \DateTimeImmutable('now', new \DateTimeZone(JalaliDateService::TIMEZONE)); + [$jy, $jm] = $this->jalali->gregorianToJalali( + (int) $now->format('Y'), + (int) $now->format('m'), + (int) $now->format('d'), + ); + + return [$jy, $jm]; + } +} diff --git a/src/Shared/Context/EntityContextResolver.php b/src/Shared/Context/EntityContextResolver.php index 7c985256..993bdc6f 100644 --- a/src/Shared/Context/EntityContextResolver.php +++ b/src/Shared/Context/EntityContextResolver.php @@ -94,6 +94,24 @@ class EntityContextResolver return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown(); } + /** + * آیا این کاربر **صاحبِ** همین محیط است؟ (ادمین همیشه بله.) + * + * «صاحب» با «می‌تواند در آن بایستد» فرق دارد: پزشکِ مهمانِ یک کلینیک در محیط آن + * می‌ایستد ولی صاحبش نیست. تصمیم‌هایی که قراردادِ کلِ مجموعه را عوض می‌کنند — + * مثل الگوی شمارهٔ پرونده — به این پرسش وصل‌اند، نه به مجوزهای per-resource. + */ + public function owns(User $user, string $entityType, ?int $entityId): bool + { + if ($user->hasRole('ROLE_ADMIN')) { + return true; + } + + [$ownedType, $ownedId] = $this->ownedEntity($user)->toEntityPair(); + + return $ownedId !== null && $ownedType === $entityType && $ownedId === $entityId; + } + /** * مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا * پرسنلِ فعالِ همان کلینیک. diff --git a/tests/Patient/RecordNumberBackfillTest.php b/tests/Patient/RecordNumberBackfillTest.php new file mode 100644 index 00000000..c479e283 --- /dev/null +++ b/tests/Patient/RecordNumberBackfillTest.php @@ -0,0 +1,80 @@ +createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $this->em->persist($clinic); + $this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic')); + $this->em->flush(); + + $row = new RecordNumberPattern('clinic', $clinic->getId()); + $row->setPattern('OLD-{SEQ:3}')->setResetPolicy(RecordNumberPattern::RESET_NONE)->setEnabled($patternEnabled); + $this->em->persist($row); + + // پروندهٔ قدیمی: بدون شماره، دقیقاً مثل ردیف‌های امروزِ دیتابیس. + $patient = $this->createUser(['ROLE_USER']); + $patient->setRealName('بیمار قدیمی'); + $record = new PatientRecord('clinic', $clinic->getId(), $patient, 'clinic', $clinic->getId()); + $this->em->persist($record); + $this->em->flush(); + + self::assertNull($record->getRecordNumber()); + + return [$owner, $clinic, $record]; + } + + /** ✅ موفق + ⚠️ مرزی: بار اول شماره می‌گیرد، بار دوم همان شماره برمی‌گردد. */ + public function testFirstOpenAssignsANumberAndTheSecondKeepsIt(): void + { + [$owner, , $record] = $this->scenario(true); + $uri = '/api/v1/patient/' . $record->getUuid(); + + $first = $this->authJson('GET', $uri, $owner); + self::assertSame(200, $this->responseCode()); + self::assertSame('OLD-001', $first['data']['record_number']); + + $second = $this->authJson('GET', $uri, $owner); + self::assertSame('OLD-001', $second['data']['record_number']); + } + + /** ⚠️ مرزی: بدون الگوی فعال، `GET` هیچ چیزی نمی‌نویسد. */ + public function testDisabledPatternLeavesTheRecordUntouched(): void + { + [$owner, , $record] = $this->scenario(false); + + $res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid(), $owner); + self::assertSame(200, $this->responseCode()); + self::assertNull($res['data']['record_number']); + } + + /** ⚠️ مرزی: پروندهٔ شماره‌دار دوباره شماره نمی‌گیرد و شمارنده مصرف نمی‌شود. */ + public function testAlreadyNumberedRecordDoesNotConsumeTheCounter(): void + { + [$owner, $clinic, $record] = $this->scenario(true); + $record->setRecordNumber('MANUAL-7'); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid(), $owner); + self::assertSame('MANUAL-7', $res['data']['record_number']); + + $pattern = $this->em->getRepository(RecordNumberPattern::class)->findForPair('clinic', $clinic->getId()); + $this->em->refresh($pattern); + self::assertSame(0, $pattern->getCounter()); + } +} diff --git a/tests/Patient/RecordNumberGeneratorTest.php b/tests/Patient/RecordNumberGeneratorTest.php new file mode 100644 index 00000000..3b7c984c --- /dev/null +++ b/tests/Patient/RecordNumberGeneratorTest.php @@ -0,0 +1,150 @@ +patternRepo(), $this->em, new JalaliDateService()); + } + + private function patternRepo(): \App\Patient\Repository\RecordNumberPatternRepository + { + return $this->em->getRepository(RecordNumberPattern::class); + } + + /** @return array{0:int,1:int} [سال، ماه] شمسی امروز — تست به تاریخ اجرا وابسته نماند */ + private function todayJalali(): array + { + $now = new \DateTimeImmutable('now', new \DateTimeZone(JalaliDateService::TIMEZONE)); + + [$jy, $jm] = (new JalaliDateService())->gregorianToJalali( + (int) $now->format('Y'), + (int) $now->format('m'), + (int) $now->format('d'), + ); + + return [$jy, $jm]; + } + + private function pattern(string $pattern, string $reset, bool $enabled = true): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + $row = new RecordNumberPattern('doctor', $doctor->getId()); + $row->setPattern($pattern)->setResetPolicy($reset)->setEnabled($enabled); + $this->em->persist($row); + $this->em->flush(); + + return $doctor; + } + + public function testRendersEveryToken(): void + { + [$jy, $jm] = $this->todayJalali(); + $g = $this->generator(); + + self::assertSame((string) $jy, $g->render('{YYYY}', 1)); + self::assertSame(substr((string) $jy, -2), $g->render('{YY}', 1)); + self::assertSame(str_pad((string) $jm, 2, '0', STR_PAD_LEFT), $g->render('{MM}', 1)); + self::assertSame('7', $g->render('{SEQ}', 7)); + self::assertSame('0007', $g->render('{SEQ:4}', 7)); + self::assertSame('MD-' . substr((string) $jy, -2) . '-0012', $g->render('MD-{YY}-{SEQ:4}', 12)); + } + + /** ⚠️ مرزی: شمارندهٔ بلندتر از پدینگ نباید بریده شود. */ + public function testCounterOverflowKeepsAllDigits(): void + { + self::assertSame('1000', $this->generator()->render('{SEQ:3}', 1000)); + } + + public function testPreviewDoesNotConsumeTheCounter(): void + { + $doctor = $this->pattern('P-{SEQ:3}', RecordNumberPattern::RESET_NONE); + $g = $this->generator(); + + self::assertSame('P-001', $g->preview('P-{SEQ:3}', 0)); + // شمارنده هنوز صفر است، پس اولین شمارهٔ واقعی همان ۱ می‌ماند. + self::assertSame('P-001', $g->next('doctor', $doctor->getId())); + } + + /** ✅ موفق: شماره‌ها پشت سر هم و بدون تکرار می‌آیند. */ + public function testNextIncrementsSequentially(): void + { + $doctor = $this->pattern('P-{SEQ:3}', RecordNumberPattern::RESET_NONE); + $g = $this->generator(); + + self::assertSame('P-001', $g->next('doctor', $doctor->getId())); + self::assertSame('P-002', $g->next('doctor', $doctor->getId())); + self::assertSame('P-003', $g->next('doctor', $doctor->getId())); + } + + /** ⚠️ مرزی: بدون الگو یا با الگوی خاموش، رفتار امروز حفظ می‌شود. */ + public function testReturnsNullWhenPatternMissingOrDisabled(): void + { + $g = $this->generator(); + + $noPattern = new Doctor($this->createUser(['ROLE_DOCTOR']), 'بدون الگو'); + $this->em->persist($noPattern); + $this->em->flush(); + self::assertNull($g->next('doctor', $noPattern->getId())); + + $disabled = $this->pattern('P-{SEQ}', RecordNumberPattern::RESET_NONE, false); + self::assertNull($g->next('doctor', $disabled->getId())); + } + + /** ⚠️ مرزی: ورود به دورهٔ جدید شمارنده را صفر می‌کند. */ + public function testCounterResetsWhenPeriodChanges(): void + { + $doctor = $this->pattern('{YY}-{SEQ:3}', RecordNumberPattern::RESET_YEARLY); + $g = $this->generator(); + [$jy] = $this->todayJalali(); + $yy = substr((string) $jy, -2); + + self::assertSame("$yy-001", $g->next('doctor', $doctor->getId())); + self::assertSame("$yy-002", $g->next('doctor', $doctor->getId())); + + // شبیه‌سازی سال قبل: مهرِ دوره را عقب می‌بریم، شمارنده باید صفر شود. + $row = $this->patternRepo()->findForPair('doctor', $doctor->getId()); + $row->resetCounter((string) ($jy - 1)); + $row->incrementCounter(); + $row->incrementCounter(); + $this->em->flush(); + + self::assertSame("$yy-001", $g->next('doctor', $doctor->getId())); + } + + /** ❌ خطا: الگوهای نامعتبر. */ + public function testValidatePatternRejectsBadInput(): void + { + $g = $this->generator(); + + self::assertNotEmpty($g->validatePattern('', RecordNumberPattern::RESET_NONE)); + self::assertNotEmpty($g->validatePattern('MD-{FOO}-{SEQ}', RecordNumberPattern::RESET_NONE)); + self::assertNotEmpty($g->validatePattern('MD-0001', RecordNumberPattern::RESET_NONE)); + self::assertNotEmpty($g->validatePattern(str_repeat('x', 61) . '{SEQ}', RecordNumberPattern::RESET_NONE)); + // {MM} بدون ریست ماهانه و {YY} بدون ریست، ترتیب را بی‌معنا می‌کنند. + self::assertNotEmpty($g->validatePattern('{MM}-{SEQ}', RecordNumberPattern::RESET_YEARLY)); + self::assertNotEmpty($g->validatePattern('{YY}-{SEQ}', RecordNumberPattern::RESET_NONE)); + + self::assertSame([], $g->validatePattern('MD-{YY}-{SEQ:4}', RecordNumberPattern::RESET_YEARLY)); + self::assertSame([], $g->validatePattern('P-{SEQ:5}', RecordNumberPattern::RESET_NONE)); + self::assertSame([], $g->validatePattern('{YY}{MM}-{SEQ:3}', RecordNumberPattern::RESET_MONTHLY)); + } +} diff --git a/tests/Patient/RecordNumberOnCreateTest.php b/tests/Patient/RecordNumberOnCreateTest.php new file mode 100644 index 00000000..e4ad9e9d --- /dev/null +++ b/tests/Patient/RecordNumberOnCreateTest.php @@ -0,0 +1,139 @@ +createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $this->em->persist($clinic); + + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر عضو'); + $this->em->persist($doctor); + $clinic->getDoctors()->add($doctor); + + $this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic')); + $this->em->flush(); + + if ($pattern !== null) { + $row = new RecordNumberPattern('clinic', $clinic->getId()); + $row->setPattern($pattern)->setResetPolicy(RecordNumberPattern::RESET_NONE)->setEnabled($enabled); + $this->em->persist($row); + $this->em->flush(); + } + + // فیچرِ `patient_records` از پلنِ «free» می‌آید که ApiTestCase تضمینش می‌کند. + return [$owner, $clinic, $doctor]; + } + + private function createPatientPayload(string $suffix): array + { + return [ + 'mobile' => '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT), + 'name' => 'بیمار ' . $suffix, + 'national_code' => (string) random_int(1_000_000_000, 9_999_999_999), + ]; + } + + /** ✅ موفق: بدون فرستادن شماره، سرور از الگو می‌سازد و دنباله جلو می‌رود. */ + public function testCreateGeneratesSequentialNumbers(): void + { + [$owner] = $this->clinicWithPattern(); + + $first = $this->authJson('POST', '/api/v1/patient', $owner, $this->createPatientPayload('الف')); + self::assertSame(201, $this->responseCode()); + self::assertSame('MD-0001', $first['data']['record_number']); + + $second = $this->authJson('POST', '/api/v1/patient', $owner, $this->createPatientPayload('ب')); + self::assertSame('MD-0002', $second['data']['record_number']); + } + + /** ⚠️ مرزی: بدون الگوی فعال، رفتار امروز حفظ می‌شود (شمارهٔ دستی، برای همه). */ + public function testWithoutPatternTheManualNumberStillWins(): void + { + [$owner] = $this->clinicWithPattern(null); + + $res = $this->authJson('POST', '/api/v1/patient', $owner, [ + ...$this->createPatientPayload('ج'), + 'record_number' => 'کاغذی-77', + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame('کاغذی-77', $res['data']['record_number']); + } + + /** ✅ موفق: صاحب مجموعه می‌تواند خارج از دنباله شماره بگذارد. */ + public function testOwnerMayOverrideTheGeneratedNumber(): void + { + [$owner] = $this->clinicWithPattern(); + + $res = $this->authJson('POST', '/api/v1/patient', $owner, [ + ...$this->createPatientPayload('د'), + 'record_number' => 'ARCHIVE-1', + ]); + + self::assertSame(201, $this->responseCode()); + self::assertSame('ARCHIVE-1', $res['data']['record_number']); + } + + /** ❌ خطا: منشی حق ورود دستی ندارد — نه در ساخت، نه در ویرایش. */ + public function testSecretaryCannotSetTheNumberManually(): void + { + [, $clinic, $doctor] = $this->clinicWithPattern(); + + $secretary = $this->createUser(['ROLE_SECRETARY']); + $rel = new DoctorSecretary($doctor, $secretary, $clinic); + $rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'create' => true, 'update' => true]]]); + $this->em->persist($rel); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); + $this->em->flush(); + + $this->authJson('POST', '/api/v1/patient', $secretary, [ + ...$this->createPatientPayload('ه'), + 'record_number' => 'HAND-9', + ]); + self::assertSame(403, $this->responseCode()); + + // بدون شمارهٔ دستی همان منشی می‌تواند پرونده بسازد و شماره از الگو می‌آید. + $ok = $this->authJson('POST', '/api/v1/patient', $secretary, $this->createPatientPayload('و')); + self::assertSame(201, $this->responseCode()); + self::assertSame('MD-0001', $ok['data']['record_number']); + } + + /** ⚠️ مرزی: پروندهٔ خودکارِ نوبت هم در همان دنباله می‌نشیند. */ + public function testAutoCreatedRecordFromAppointmentGetsANumber(): void + { + [, $clinic, $doctor] = $this->clinicWithPattern(); + + $patient = $this->createUser(['ROLE_USER']); + $patient->setRealName('بیمار نوبت'); + $this->em->flush(); + + $appointment = new \App\Appointment\Entity\Appointment($doctor, $patient, time() + 3_600, time() + 5_400); + $appointment->setClinic($clinic); + $appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $clinic)); + $this->em->persist($appointment); + $this->em->flush(); + + $service = static::getContainer()->get(\App\Patient\Service\PatientService::class); + $session = $service->autoCreateOnAppointmentConfirm($appointment); + + self::assertNotNull($session); + self::assertSame('MD-0001', $session->getRecord()->getRecordNumber()); + } +} diff --git a/tests/Patient/RecordNumberSettingsApiTest.php b/tests/Patient/RecordNumberSettingsApiTest.php new file mode 100644 index 00000000..6667e4d1 --- /dev/null +++ b/tests/Patient/RecordNumberSettingsApiTest.php @@ -0,0 +1,152 @@ +createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $this->em->persist($clinic); + + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر عضو'); + $this->em->persist($doctor); + $clinic->getDoctors()->add($doctor); + + $this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic')); + $this->em->flush(); + + return [$owner, $clinic, $doctor]; + } + + public function testOwnerReadsDefaultsBeforeAnythingIsSaved(): void + { + [$owner] = $this->clinic(); + + $res = $this->authJson('GET', self::URI, $owner); + self::assertSame(200, $this->responseCode()); + self::assertFalse($res['data']['enabled']); + self::assertSame('none', $res['data']['reset_policy']); + self::assertSame(0, $res['data']['counter']); + self::assertTrue($res['data']['can_edit']); + } + + /** ✅ موفق: ذخیرهٔ الگو و پیش‌نمایش شمارهٔ بعدی. */ + public function testOwnerSavesPatternAndGetsPreview(): void + { + [$owner] = $this->clinic(); + + $res = $this->authJson('PUT', self::URI, $owner, [ + 'enabled' => true, + 'pattern' => 'MD-{SEQ:4}', + 'reset_policy' => 'none', + ]); + + self::assertSame(200, $this->responseCode()); + self::assertTrue($res['data']['enabled']); + self::assertSame('MD-{SEQ:4}', $res['data']['pattern']); + self::assertSame('MD-0001', $res['data']['next_preview']); + + // GET بعدی همان مقدارِ ذخیره‌شده را می‌دهد (نه پیش‌فرض). + $read = $this->authJson('GET', self::URI, $owner); + self::assertSame('MD-{SEQ:4}', $read['data']['pattern']); + } + + /** ❌ خطا: منشی نه می‌نویسد، نه `can_edit` می‌گیرد. */ + public function testSecretaryCannotWriteButCanRead(): void + { + [, $clinic, $doctor] = $this->clinic(); + + $secretary = $this->createUser(['ROLE_SECRETARY']); + $this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic)); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); + $this->em->flush(); + + $read = $this->authJson('GET', self::URI, $secretary); + self::assertSame(200, $this->responseCode()); + self::assertFalse($read['data']['can_edit']); + + $this->authJson('PUT', self::URI, $secretary, [ + 'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'none', + ]); + self::assertSame(403, $this->responseCode()); + } + + /** ❌ خطا: پزشکِ مهمانِ کلینیک هم صاحب آن محیط نیست. */ + public function testGuestDoctorCannotChangeTheClinicPattern(): void + { + [, $clinic, $doctor] = $this->clinic(); + $this->em->persist(new UserActiveContext($doctor->getUser(), $clinic->getUuid(), 'clinic')); + $this->em->flush(); + + $this->authJson('PUT', self::URI, $doctor->getUser(), [ + 'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'none', + ]); + self::assertSame(403, $this->responseCode()); + } + + /** ❌ خطا: الگوهای نامعتبر همگی ۴۲۲ با فیلد درست. */ + public function testInvalidPatternsAreRejected(): void + { + [$owner] = $this->clinic(); + + foreach ([ + ['MD-{FOO}-{SEQ}', 'none'], // توکن ناشناخته + ['MD-0001', 'none'], // بدون {SEQ} + ['', 'none'], // خالی + ['{YY}-{SEQ}', 'none'], // سال بدون ریست + ['{MM}-{SEQ}', 'yearly'], // ماه بدون ریست ماهانه + ] as [$pattern, $reset]) { + $res = $this->authJson('PUT', self::URI, $owner, [ + 'enabled' => true, 'pattern' => $pattern, 'reset_policy' => $reset, + ]); + self::assertSame(422, $this->responseCode(), "pattern: $pattern"); + self::assertSame('pattern', $res['errors'][0]['field'] ?? null); + } + + // سیاست ریستِ ناشناخته هم رد می‌شود، ولی فیلدش reset_policy است. + $res = $this->authJson('PUT', self::URI, $owner, [ + 'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'weekly', + ]); + self::assertSame(422, $this->responseCode()); + self::assertSame('reset_policy', $res['errors'][0]['field'] ?? null); + } + + /** ⚠️ مرزی: تغییر الگو شمارنده را صفر نمی‌کند — شماره‌های صادرشده وجود دارند. */ + public function testChangingThePatternKeepsTheCounter(): void + { + [$owner, $clinic] = $this->clinic(); + + $this->authJson('PUT', self::URI, $owner, [ + 'enabled' => true, 'pattern' => 'A-{SEQ:3}', 'reset_policy' => 'none', + ]); + + $repo = $this->em->getRepository(\App\Patient\Entity\RecordNumberPattern::class); + $row = $repo->findForPair('clinic', $clinic->getId()); + $row->incrementCounter(); + $row->incrementCounter(); + $this->em->flush(); + + $res = $this->authJson('PUT', self::URI, $owner, [ + 'enabled' => true, 'pattern' => 'B-{SEQ:3}', 'reset_policy' => 'none', + ]); + + self::assertSame(2, $res['data']['counter']); + self::assertSame('B-003', $res['data']['next_preview']); + } +}