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 لازم نیست — ولی اگر در حین کار خلافش دیده شد، گزارش بده.