feat(patient): implement record number pattern management

- Add RecordNumberSettingsController for managing patient record number patterns.
- Create RecordNumberPattern entity to represent the pattern configuration.
- Implement RecordNumberPatternRepository for database interactions.
- Develop RecordNumberGenerator service for generating and validating record numbers.
- Add tests for record number generation, backfilling, and API interactions.
- Ensure proper access control for viewing and updating patterns based on user roles.
This commit is contained in:
hamed
2026-08-04 12:30:17 +03:30
parent 9b8eef9598
commit 85a27812c7
24 changed files with 2009 additions and 19 deletions
@@ -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, 'شماره پرونده الزامی است'),
...
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
```
---
## وظایف
### ۱. 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` برای سیاست ریست (**نه** `<select>` بومی — قاعدهٔ پروژه)، و یک خط پیش‌نمایشِ زنده: «شمارهٔ بعدی: MD-05-0013».
- راهنمای کوتاه توکن‌ها زیر فیلد.
- وقتی `can_edit === false`: فرم read-only + یک خط توضیح که فقط صاحب حساب می‌تواند تغییر دهد.
- route در `App.tsx` با `RoleRoute roles={['doctor','clinic','secretary']} blockClinicScope permission={['patients','view']}` و آیتم در `settingsMenu.ts` (کلید `record-number`، آیکون `HashtagIcon`، همان `perm`).
**ب) فرم پرونده** `PatientRecordFormPage.tsx`:
- وقتی الگو فعال است و کاربر اجازهٔ دستی ندارد: فیلد `record_number` **در حالت ساخت** پنهان/read-only شود و قاعدهٔ zod از `min(1)` به اختیاری تغییر کند — وگرنه فرم با فیلدی که کاربر نمی‌تواند پر کند قفل می‌شود. در حالت ویرایش، رفتار فعلی برای صاحب حساب می‌ماند.
- مقدار الگو/دسترسی از همان `GET /api/v1/patient-record-number-settings` (یک هوک `useRecordNumberSettings` در `assets/admin/hooks/`).
**نحوه تست:** `npx tsc --noEmit` + `npx vitest run assets/admin/pages/RecordNumberSettingsPage.test.tsx` با سه سناریو: ذخیرهٔ الگو، پیش‌نمایش درست، و read-only بودن فرم وقتی `can_edit === false`. برای فرم پرونده یک تست که با الگوی فعال، ارسال بدون `record_number` را مجاز بداند.
---
### ۸. مستندات
- `docs/api/patient.md`: بخش تازه برای دو endpoint تنظیمات (method/path/permission، بدنهٔ کامل request، JSON واقعیِ response از اجرای واقعی، همهٔ کدهای خطا) + به‌روزرسانی توضیح `record_number` در `POST /api/v1/patient` و `PATCH /patient/{uuid}` (چه کسی می‌تواند دستی بفرستد، چه زمانی سرور تولید می‌کند).
- در همان فایل بنویس که `GET /api/v1/patient/{uuid}` ممکن است شمارهٔ پرونده را تخصیص دهد (اثر جانبیِ عمدی روی یک `GET` — سند بدون این، رفتار را غافلگیرکننده می‌کند).
## نکات مهم
- **الگوی طراحی:** یک سرویسِ تکی (`RecordNumberGenerator`) کافی است؛ Strategy برای «انواع الگو» نساز — الان فقط یک زبانِ توکن وجود دارد و abstraction دوم مصرف ندارد (guidelines §۵). ریست‌ها فقط سه مقدار ثابت‌اند و با یک `match` روی `periodKey()` حل می‌شوند، نه سه کلاس.
- **جداسازی محیط:** جدول جدید `entity_type/entity_id` دارد، پس یا `TenantOwnedTrait` بگیرد یا با دلیل در `GlobalTables` ثبت شود — `TenantSchemaCoverageTest` در غیر این صورت قرمز می‌شود. (مسیر درست: `TenantOwnedTrait`.)
- **شمارنده روی همان ردیفِ تنظیمات است**، نه `MAX(record_number)+1`. شمارش از روی مقادیر موجود، با شمارهٔ دستیِ صاحب حساب یا با تغییر الگو می‌شکند.
- **گیتِ ورود دستی سمت سرور اجباری است.** پنهان‌کردن فیلد در UI کافی نیست؛ منشی می‌تواند مستقیم به API بزند.
- **رفتار قبلی نباید بشکند:** محیطی که الگو ندارد یا `enabled=false` است باید دقیقاً مثل امروز کار کند (ورود دستی آزاد، شماره تولید نشود). این را تست کن.
- **`record_number` در جستجوی بیماران استفاده می‌شود** (`/api/v1/patients?search=`, placeholder «جستجوی نام، شماره تماس، شماره پرونده...»). بعد از این تغییر، جستجو با شمارهٔ تولیدشده را دستی امتحان کن.
- **مصرف‌کنندهٔ دیگر ندارد:** `nobat724_front` و `clinic-pro-tauri` این فیلد را نمی‌خوانند (grep شد)، پس تغییر cross-repo لازم نیست — ولی اگر در حین کار خلافش دیده شد، گزارش بده.
+2
View File
@@ -70,6 +70,7 @@ import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
import SettingsMenuPage from './pages/SettingsMenuPage';
import AccountSettingsPage from './pages/AccountSettingsPage';
import TagsSettingsPage from './pages/TagsSettingsPage';
import RecordNumberSettingsPage from './pages/RecordNumberSettingsPage';
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
@@ -283,6 +284,7 @@ export default function App() {
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
<Route path="record-number-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['patients', 'view']}><RecordNumberSettingsPage /></RoleRoute>} />
<Route path="appointment-settings" element={<RoleRoute roles={['doctor', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><AppointmentSettingsPage /></RoleRoute>} />
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><SubscriptionPage /></RoleRoute>} />
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['discounts', 'view']}><DiscountsPage /></RoleRoute>} />
@@ -3,7 +3,7 @@ import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
RectangleStackIcon,
RectangleStackIcon, HashtagIcon,
} from '@heroicons/react/24/outline';
/**
@@ -44,6 +44,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
{ key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] },
{ key: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] },
{ key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] },
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true },
];
@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
export type RecordNumberResetPolicy = 'none' | 'yearly' | 'monthly';
export interface RecordNumberSettings {
enabled: boolean;
pattern: string;
reset_policy: RecordNumberResetPolicy;
counter: number;
/** شمارهٔ بعدی، بدون مصرف‌کردن شمارنده. */
next_preview: string;
/** فقط صاحب مجموعه (و ادمین) اجازهٔ ذخیره دارد؛ گیت اصلی سمت سرور است. */
can_edit: boolean;
}
const KEY = ['record-number-settings'];
/**
* الگوی شمارهٔ پروندهٔ محیط جاری.
*
* هم صفحهٔ تنظیمات از آن می‌خواند، هم فرم پرونده — فرم باید بداند شماره خودکار
* ساخته می‌شود یا هنوز دستی است.
*/
export function useRecordNumberSettings() {
const qc = useQueryClient();
const query = useQuery({
queryKey: KEY,
queryFn: () => api.get<ApiResponse<RecordNumberSettings>>('/api/v1/patient-record-number-settings'),
staleTime: 60_000,
// منشیِ بدون مجوز `patients.view` اینجا ۴۰۳ می‌گیرد؛ تلاش دوباره چیزی عوض نمی‌کند.
retry: false,
});
const save = useMutation({
mutationFn: (d: Pick<RecordNumberSettings, 'enabled' | 'pattern' | 'reset_policy'>) =>
api.put<ApiResponse<RecordNumberSettings>>('/api/v1/patient-record-number-settings', d),
onSuccess: () => {
toast.success('الگوی شماره پرونده ذخیره شد');
qc.invalidateQueries({ queryKey: KEY });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ الگو ناموفق بود'),
});
return { settings: query.data?.data ?? null, loading: query.isLoading, save };
}
@@ -0,0 +1,91 @@
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 PatientRecordFormPage from './PatientRecordFormPage';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
/** الگوی شمارهٔ پرونده؛ `enabled=false` یعنی رفتار قدیمیِ ورود دستی. */
function mockPattern(settings: Record<string, unknown>) {
get.mockImplementation((url: string) =>
url.includes('patient-record-number-settings')
? Promise.resolve({ success: true, data: { pattern: '{YY}-{SEQ:4}', reset_policy: 'none', counter: 0, next_preview: '05-0001', ...settings } })
: Promise.resolve({ success: true, data: [] }),
);
}
/**
* پرکردن فیلدهای الزامیِ دیگر تا فقط شمارهٔ پرونده متغیر بماند.
*
* اول منتظر رسیدن تنظیمات می‌ماند: رندرِ بعد از پاسخ، منوی باز react-select را
* می‌بندد و انتخاب جنسیت را می‌خورد.
*/
async function fillRequiredFields() {
await waitFor(() => expect(get).toHaveBeenCalledWith(expect.stringContaining('patient-record-number-settings')));
await waitFor(() => expect(screen.getByRole('button', { name: /ثبت|ذخیره/ })).toBeEnabled());
fireEvent.change(await screen.findByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'علی محمدی' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '0012345675' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09121234567' } });
const gender = document.getElementById('patient-gender-select') as HTMLInputElement;
fireEvent.focus(gender);
fireEvent.keyDown(gender, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('مرد'));
}
beforeEach(() => {
get.mockReset();
post.mockReset();
post.mockResolvedValue({ success: true, data: { uuid: 'rec-new' } });
(api.patch as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, data: {} });
});
describe('PatientRecordFormPage — شمارهٔ پرونده', () => {
/** ⚠️ مرزی: بدون الگو، فیلد الزامی می‌ماند (رفتار امروز). */
it('بدون الگو شماره پرونده الزامی است', async () => {
mockPattern({ enabled: false, can_edit: true });
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
await fillRequiredFields();
fireEvent.click(screen.getByText('ثبت اطلاعات') ?? screen.getByRole('button', { name: /ثبت|ذخیره/ }));
expect(await screen.findByText('شماره پرونده الزامی است')).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
});
/** ✅ موفق: با الگوی فعال و بدون اجازهٔ دستی، فیلد read-only است و ارسال نمی‌شود. */
it('با الگوی فعال و بدون can_edit، شماره فرستاده نمی‌شود', async () => {
mockPattern({ enabled: true, can_edit: false });
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
expect(await screen.findByText('شماره از الگوی مجموعه ساخته می‌شود')).toBeInTheDocument();
await fillRequiredFields();
fireEvent.click(screen.getByRole('button', { name: /ثبت|ذخیره/ }));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1]).not.toHaveProperty('record_number');
});
/** ✅ موفق: صاحب مجموعه می‌تواند خالی بگذارد تا سرور بسازد. */
it('صاحب مجموعه می‌تواند شماره را خالی بگذارد', async () => {
mockPattern({ enabled: true, can_edit: true });
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
expect(await screen.findByText(/خالی بماند تا از الگو ساخته شود/)).toBeInTheDocument();
await fillRequiredFields();
fireEvent.click(screen.getByRole('button', { name: /ثبت|ذخیره/ }));
await waitFor(() => expect(post).toHaveBeenCalled());
expect(post.mock.calls[0][1].record_number).toBe('');
});
});
@@ -11,21 +11,30 @@ vi.mock('../lib/api', () => ({
import { api } from '../lib/api';
import PatientRecordFormPage from './PatientRecordFormPage';
/** react-select (SearchableSelect) را با placeholder پیدا و گزینه را با متن انتخاب می‌کند. */
async function pickSelect(placeholder: string, optionLabel: string) {
const ph = await screen.findByText(placeholder);
const control = ph.closest('div[class*="control"]') as HTMLElement;
const input = control.querySelector('input') as HTMLInputElement;
/** جنسیت را از SearchableSelect انتخاب می‌کند (شناسهٔ ورودی، نه placeholder). */
async function pickGender(optionLabel: string) {
// فلاشِ رندرهای در صف (کوئریِ تنظیمات)؛ رندرِ وسط تعامل، منوی react-select را می‌بندد.
await waitFor(() => expect(document.getElementById('patient-gender-select')).toBeInTheDocument());
await new Promise((r) => setTimeout(r, 0));
const input = document.getElementById('patient-gender-select') as HTMLInputElement;
fireEvent.focus(input);
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.click(await screen.findByText(optionLabel));
}
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const patch = api.patch as ReturnType<typeof vi.fn>;
beforeEach(() => {
post.mockReset(); patch.mockReset();
get.mockReset(); post.mockReset(); patch.mockReset();
// صفحه الگوی شمارهٔ پرونده را می‌خواند؛ بدون mock، ردِ همان کوئری وسط تعامل
// یک رندر می‌آورد و منوی باز react-select را می‌بندد.
get.mockResolvedValue({
success: true,
data: { enabled: false, pattern: '{YY}-{SEQ:4}', reset_policy: 'none', counter: 0, next_preview: '05-0001', can_edit: true },
});
post.mockResolvedValue({ success: true, data: { uuid: 'new-1' } });
patch.mockResolvedValue({ success: true, data: {} });
});
@@ -33,10 +42,12 @@ beforeEach(() => {
describe('PatientRecordFormPage (تشکیل پرونده)', () => {
it('creates a record then patches the demographic fields', async () => {
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
// اول تنظیمات الگو برسد؛ رندرِ بعد از پاسخ، منوی باز react-select را می‌بندد.
await waitFor(() => expect(get).toHaveBeenCalled());
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'بیمار نمونه' } });
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1001' } });
await pickSelect('انتخاب...', 'زن'); // gender = female
await pickGender('زن'); // gender = female
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '1234567890' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
@@ -49,10 +60,12 @@ describe('PatientRecordFormPage (تشکیل پرونده)', () => {
it('blocks submit and shows a validation error for a bad national code', async () => {
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
// اول تنظیمات الگو برسد؛ رندرِ بعد از پاسخ، منوی باز react-select را می‌بندد.
await waitFor(() => expect(get).toHaveBeenCalled());
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'ب' } });
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1' } });
await pickSelect('انتخاب...', 'مرد');
await pickGender('مرد');
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '12' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
+54 -7
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -14,12 +14,15 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
const schema = z.object({
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
// الزامی‌بودنش شرطی است: با الگوی فعال، سرور شماره را می‌سازد و این فیلد اصلاً
// فرستاده نمی‌شود. اعتبارسنجیِ شرطی در `superRefine` پایین‌تر است.
record_number: z.string(),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
@@ -39,8 +42,25 @@ export default function PatientRecordFormPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const { settings } = useRecordNumberSettings();
/** الگو روشن است ⇒ شماره را سرور می‌سازد. */
const autoNumbered = !!settings?.enabled;
/** ورود دستی: وقتی الگویی نیست، یا کاربر صاحب مجموعه است (سرور هم همین را می‌سنجد). */
const manualAllowed = !autoNumbered || !!settings?.can_edit;
const formSchema = useMemo(
() => schema.superRefine((v, ctx) => {
// فقط وقتی شماره دستی است الزامی می‌ماند؛ وگرنه کاربر با فیلدی که نمی‌تواند
// پرش کند پشت فرمِ قفل‌شده می‌ماند.
if (!autoNumbered && !(v.record_number ?? '').trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['record_number'], message: 'شماره پرونده الزامی است' });
}
}),
[autoNumbered],
);
const form = useForm<Form>({
resolver: zodResolver(schema),
resolver: zodResolver(formSchema),
defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' },
});
@@ -74,14 +94,18 @@ export default function PatientRecordFormPage() {
referral_source: d.referral_source || null,
description: d.description || null,
};
// شمارهٔ پرونده فقط وقتی فرستاده می‌شود که ورود دستی مجاز باشد؛ وگرنه سرور
// ۴۰۳ می‌دهد و سرورست که شماره را از الگو می‌سازد.
const numberPayload = manualAllowed ? { record_number: d.record_number ?? '' } : {};
if (isEdit) {
return api.patch(`/api/v1/patient/${uuid}`, {
name: d.name, national_code: d.national_code, mobile: d.mobile, record_number: d.record_number, ...profilePayload,
name: d.name, national_code: d.national_code, mobile: d.mobile, ...numberPayload, ...profilePayload,
});
}
// create: POST creates the record + identity, then PATCH applies the profile demographics
const created = await api.post<ApiResponse<PatientRecord>>('/api/v1/patient', {
name: d.name, mobile: d.mobile, national_code: d.national_code, record_number: d.record_number,
name: d.name, mobile: d.mobile, national_code: d.national_code, ...numberPayload,
});
const newUuid = (created as any)?.data?.uuid;
if (newUuid) await api.patch(`/api/v1/patient/${newUuid}`, profilePayload);
@@ -116,11 +140,34 @@ export default function PatientRecordFormPage() {
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
<div className="field"><input {...form.register('name')} placeholder="نام و نام خانوادگی را وارد نمایید" /></div>
</Field>
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
<Field
label="شماره پرونده"
required={!autoNumbered}
error={form.formState.errors.record_number?.message}
>
{manualAllowed ? (
<div className="field">
<input
{...form.register('record_number')}
placeholder={autoNumbered ? (settings?.next_preview ?? 'شماره پرونده') : 'شماره پرونده'}
/>
</div>
) : (
<div className="field" style={{ opacity: 0.7 }}>
<input value={isEdit ? (recordData?.data?.record_number ?? '') : (settings?.next_preview ?? '')} readOnly dir="ltr" />
</div>
)}
{autoNumbered && (
<span style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'block', marginTop: 4 }}>
{manualAllowed
? `خالی بماند تا از الگو ساخته شود (شمارهٔ بعدی: ${settings?.next_preview ?? '—'})`
: 'شماره از الگوی مجموعه ساخته می‌شود'}
</span>
)}
</Field>
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
<SearchableSelect
inputId="patient-gender-select"
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
value={form.watch('gender') ?? null}
onChange={(v) => form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })}
@@ -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<typeof vi.fn>;
const put = api.put as ReturnType<typeof vi.fn>;
function mockSettings(overrides: Record<string, unknown> = {}) {
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(<RecordNumberSettingsPage />);
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(<RecordNumberSettingsPage />);
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(<RecordNumberSettingsPage />);
const btn = await screen.findByText('ذخیره تنظیمات');
expect(btn).toBeDisabled();
});
/** ❌ خطا: کاربری که صاحب مجموعه نیست فقط می‌بیند. */
it('بدون can_edit فرم فقط خواندنی است', async () => {
mockSettings({ can_edit: false, enabled: true });
renderWithProviders(<RecordNumberSettingsPage />);
expect(await screen.findByText(/تغییر الگو فقط با حساب صاحب مجموعه/)).toBeInTheDocument();
expect(screen.getByLabelText('الگوی شماره')).toHaveAttribute('readonly');
expect(screen.queryByText('ذخیره تنظیمات')).toBeNull();
});
});
@@ -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<RecordNumberResetPolicy>('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 (
<SettingsLayout active="record-number">
<div
style={{ background: 'var(--surface)', minWidth: 0 }}
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
>
<h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1>
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه چه از فرم تشکیل پرونده و چه از
نوبتی که قطعی میشود بر اساس همین الگو ساخته میشود.
</p>
{loading ? (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
) : !settings ? (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 13.5 }}>
دسترسی به تنظیمات شمارهٔ پرونده ندارید.
</div>
) : (
<>
{readOnly && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--warning-bg)', color: 'var(--text-2)', fontSize: 12.5,
}}>
تغییر الگو فقط با حساب صاحب مجموعه ممکن است؛ اینجا فقط مقدار فعلی را میبینید.
</div>
)}
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: readOnly ? 'not-allowed' : 'pointer' }}>
<span style={{
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
background: enabled ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
opacity: readOnly ? 0.6 : 1,
}}>
<input
type="checkbox"
role="switch"
aria-label="شماره‌گذاری خودکار پرونده"
checked={enabled}
disabled={readOnly}
onChange={(e) => setEnabled(e.target.checked)}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'inherit' }}
/>
<span style={{
position: 'absolute', top: 2, insetInlineStart: enabled ? 22 : 2, width: 18, height: 18,
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s',
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
}} />
</span>
شمارهگذاری خودکار پرونده
</label>
<div style={{ maxWidth: 420, marginTop: 18 }}>
<label className="field-label" htmlFor="record-number-pattern">الگوی شماره</label>
<div className="field" style={{ marginTop: 6 }}>
<input
id="record-number-pattern"
value={pattern}
dir="ltr"
readOnly={readOnly}
onChange={(e) => setPattern(e.target.value)}
placeholder="MD-{YY}-{SEQ:4}"
/>
</div>
<ul style={{ margin: '10px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 4 }}>
{TOKENS.map(([token, hint]) => (
<li key={token} style={{ fontSize: 12, color: 'var(--text-3)' }}>
<code dir="ltr" style={{ color: 'var(--primary)' }}>{token}</code> {hint}
</li>
))}
</ul>
</div>
<div style={{ maxWidth: 420, marginTop: 18 }}>
<label className="field-label" id="reset-policy-label">ریست شمارنده</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
options={RESET_OPTIONS}
value={resetPolicy}
onChange={(v) => setResetPolicy((v ?? 'none') as RecordNumberResetPolicy)}
ariaLabelledBy="reset-policy-label"
isDisabled={readOnly}
height={44}
/>
</div>
</div>
<div style={{
marginTop: 18, padding: '12px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)', maxWidth: 420,
}}>
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>شمارهٔ بعدی: </span>
<b dir="ltr" style={{ fontSize: 14, color: 'var(--text)' }}>{settings.next_preview}</b>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
{dirty
? 'پیش‌نمایش پس از ذخیره به‌روز می‌شود.'
: `شمارنده تا اینجا ${settings.counter} پرونده را شماره‌گذاری کرده است.`}
</div>
</div>
{!readOnly && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 22 }}>
<button
type="button"
className="btn primary"
disabled={!dirty || save.isPending}
onClick={() => save.mutate({ enabled, pattern, reset_policy: resetPolicy })}
>
{save.isPending ? 'در حال ذخیره…' : 'ذخیره تنظیمات'}
</button>
</div>
)}
</>
)}
</div>
</SettingsLayout>
);
}
+80 -1
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* الگوی شمارهٔ پروندهٔ هر محیط + شمارندهٔ همان الگو.
*
* `diff` دو تغییر بی‌ربط را هم برداشته بود (جدول `messenger_messages` که خودِ
* transport می‌سازد، و rename یک ایندکس روی `clinic_resources` که drift قبلی است)؛
* هر دو حذف شدند تا این migration فقط همین قابلیت را حمل کند.
*/
final class Version20260804082035 extends AbstractMigration
{
public function getDescription(): string
{
return 'Per-tenant patient record number pattern with its own counter';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* یکتایی شمارهٔ پرونده در هر محیط.
*
* پیش از اجرا روی دادهٔ واقعی بررسی شد که شمارهٔ تکراری وجود ندارد. چند `NULL` در
* unique index مجاز است، پس پرونده‌های بی‌شماره (که تا پیش از این قابلیت اکثریت‌اند)
* مانع نمی‌شوند.
*/
final class Version20260804082400 extends AbstractMigration
{
public function getDescription(): string
{
return 'Unique patient record number per tenant';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+60 -2
View File
@@ -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) {
@@ -0,0 +1,140 @@
<?php
namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Patient\Entity\RecordNumberPattern;
use App\Patient\Repository\RecordNumberPatternRepository;
use App\Patient\Service\RecordNumberGenerator;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* الگوی شمارهٔ پروندهٔ محیط جاری.
*
* خواندن برای هر کسی که بیماران را می‌بیند (فرم پرونده باید بداند شماره خودکار
* می‌آید یا نه)، ولی نوشتن فقط برای **صاحب محیط**: شمارهٔ پرونده قراردادِ ثبتِ کل
* مجموعه است و منشی نباید وسط کار دنباله را عوض کند.
*/
#[OA\Tag(name: 'Patient')]
class RecordNumberSettingsController extends BaseController
{
public function __construct(
private readonly RecordNumberPatternRepository $patternRepo,
private readonly RecordNumberGenerator $generator,
private readonly EntityContextResolver $contextResolver,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
#[OA\Get(
path: '/api/v1/patient-record-number-settings',
summary: 'الگوی شمارهٔ پروندهٔ محیط جاری',
security: [['bearerAuth' => []]],
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<string, mixed> */
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];
}
}
+3
View File
@@ -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]
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Patient\Entity;
use App\Patient\Repository\RecordNumberPatternRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* الگوی شمارهٔ پروندهٔ یک محیط — یک ردیف به ازای هر مطب/کلینیک.
*
* شمارنده روی همین ردیف می‌نشیند، نه `MAX(record_number)+1`: شمارش از روی مقادیر
* موجود با شمارهٔ دستیِ صاحب حساب یا با تغییر الگو می‌شکند.
*
* `counterPeriod` مهرِ دورهٔ فعلیِ شمارنده است (`1405` سالانه، `1405-05` ماهانه،
* `null` بدون ریست). بدون این ستون، «آیا سال عوض شده؟» فقط با حدس از `updatedAt`
* قابل جواب بود.
*
* جفت محیط را مثل {@see \App\Insurance\Entity\TenantServiceCategorySetting} خودش
* اعلام می‌کند و `TenantOwnedTrait` نمی‌گیرد: آن trait برای `assignTenant()` یک
* `EntityContext` می‌خواهد، ولی تولیدکنندهٔ شماره فقط جفت `(type, id)` را دارد.
*/
#[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';
public const RESET_POLICIES = [self::RESET_NONE, self::RESET_YEARLY, self::RESET_MONTHLY];
/** الگوی پیش‌فرضِ ردیفِ تازه‌ساخته — تا فرمِ تنظیمات با فیلد خالی باز نشود. */
public const DEFAULT_PATTERN = '{YY}-{SEQ:4}';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'boolean', options: ['default' => 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();
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\RecordNumberPattern;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\LockMode;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<RecordNumberPattern>
*/
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();
}
}
}
+5
View File
@@ -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);
}
@@ -0,0 +1,149 @@
<?php
namespace App\Patient\Service;
use App\Patient\Entity\RecordNumberPattern;
use App\Patient\Repository\RecordNumberPatternRepository;
use App\Representation\Service\JalaliDateService;
use Doctrine\ORM\EntityManagerInterface;
/**
* تنها جایی که شمارهٔ پرونده ساخته می‌شود.
*
* سه مسئولیت جدا دارد: رندرِ خالصِ الگو، گرفتنِ شمارهٔ بعدی (تراکنشی)، و پیش‌نمایش
* بدون مصرفِ شمارنده. هر سه مسیرِ ساختِ پرونده — فرم پنل، ساخت خودکار از نوبت، و
* backfillِ پروندهٔ قدیمی — از همین‌جا شماره می‌گیرند تا منطق سه‌جا کپی نشود.
*/
class RecordNumberGenerator
{
/** توکن‌های مجاز؛ هر چیز دیگری در اعتبارسنجی الگو رد می‌شود. */
private const TOKEN_RE = '/\{(YYYY|YY|MM|SEQ(?::(\d+))?)\}/';
public function __construct(
private readonly RecordNumberPatternRepository $repo,
private readonly EntityManagerInterface $em,
private readonly JalaliDateService $jalali,
) {}
/**
* شمارهٔ بعدیِ همین محیط، یا `null` وقتی الگویی تعریف/فعال نشده — یعنی «رفتار
* امروز»: شماره دستی می‌ماند.
*
* کل کار داخل یک تراکنش با قفلِ ردیفِ الگوست: خواندن-افزایش-نوشتنِ بدون قفل، دو
* درخواستِ هم‌زمان را به یک شماره می‌رساند.
*/
public function next(string $entityType, int $entityId): ?string
{
return $this->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<string> خالی یعنی معتبر
*/
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];
}
}
@@ -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;
}
/**
* مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا
* پرسنلِ فعالِ همان کلینیک.
@@ -0,0 +1,80 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\RecordNumberPattern;
use App\Tests\ApiTestCase;
/**
* پرونده‌های ساخته‌شده پیش از فعال‌شدن الگو، اولین بار که باز می‌شوند شماره می‌گیرند
* (`GET /api/v1/patient/{uuid}`) و از آن به بعد همان شماره را نگه می‌دارند.
*/
class RecordNumberBackfillTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Clinic, 2: PatientRecord} */
private function scenario(bool $patternEnabled): array
{
$owner = $this->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());
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\RecordNumberPattern;
use App\Patient\Service\RecordNumberGenerator;
use App\Representation\Service\JalaliDateService;
use App\Tests\ApiTestCase;
/**
* تولید شمارهٔ پرونده: رندر توکن‌ها، اعتبارسنجی الگو، و شمارندهٔ تراکنشی.
*/
class RecordNumberGeneratorTest extends ApiTestCase
{
/**
* سرویس‌های دامنه در کانتینرِ تست public نیستند؛ ساختن مستقیم با همان
* وابستگی‌های تزریقی، هم قرارداد را حفظ می‌کند هم تست را از کانتینر جدا می‌کند.
*/
private function generator(): RecordNumberGenerator
{
return new RecordNumberGenerator($this->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));
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\RecordNumberPattern;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* شمارهٔ پرونده هنگام ساختِ پرونده از الگو ساخته می‌شود — و ورودِ دستی فقط از
* صاحب مجموعه پذیرفته می‌شود.
*/
class RecordNumberOnCreateTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: Doctor} */
private function clinicWithPattern(?string $pattern = 'MD-{SEQ:4}', bool $enabled = true): array
{
$owner = $this->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());
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Tests\Patient;
use App\Auth\Entity\User;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* GET/PUT /api/v1/patient-record-number-settings — الگوی شمارهٔ پروندهٔ محیط جاری.
* خواندن برای هر کسی که بیماران را می‌بیند؛ نوشتن فقط برای صاحب محیط.
*/
class RecordNumberSettingsApiTest extends ApiTestCase
{
private const URI = '/api/v1/patient-record-number-settings';
/** @return array{0: User, 1: Clinic, 2: Doctor} مالک کلینیک + کلینیک + پزشکِ عضو */
private function clinic(): array
{
$owner = $this->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']);
}
}