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

22 KiB
Raw Blame History

الگوی شماره پرونده — تنظیمات 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_number201 و 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 — ستون بدون قید یکتایی:

    // 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 — ساخت دستی، شماره فقط اگر کاربر فرستاده باشد:

        $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 — ساخت خودکار، بدون هیچ شماره‌ای:

        $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 — فیلد دستیِ الزامی:

  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 عدد صحیح):

#[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 قابل جواب بود.

سپس:

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;" وجود داشته باشد.


۲. یکتایی شمارهٔ پرونده در سطح دیتابیس

قبل از افزودن ایندکس، تداخل‌های موجود را ببین:

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() می‌آید — پیاده‌سازی دستی ننویس؛ همان فایل توضیح می‌دهد نسخهٔ دست‌ساز قبلی غلط بود.

ب) گرفتن شمارهٔ بعدی (تراکنشی):

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:

{ "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().

نحوه تست:

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 — منطق فعلیِ خط ۷۸۶ عوض می‌شود:

$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 (خط ~۲۱۱) — همان یک خط:

$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 (خط ۷۹۹)، قبل از ساخت پاسخ:

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