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