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