Files
clinicpro/.claude/prompt/patient-record-number-pattern.md
T
hamed 85a27812c7 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.
2026-08-04 12:30:17 +03:30

328 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# الگوی شماره پرونده — تنظیمات 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 لازم نیست — ولی اگر در حین کار خلافش دیده شد، گزارش بده.