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