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.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
# الگوی شماره پرونده — تنظیمات 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 لازم نیست — ولی اگر در حین کار خلافش دیده شد، گزارش بده.
|
||||
@@ -70,6 +70,7 @@ import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
|
||||
import SettingsMenuPage from './pages/SettingsMenuPage';
|
||||
import AccountSettingsPage from './pages/AccountSettingsPage';
|
||||
import TagsSettingsPage from './pages/TagsSettingsPage';
|
||||
import RecordNumberSettingsPage from './pages/RecordNumberSettingsPage';
|
||||
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
|
||||
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
|
||||
import PatientsListPage from './pages/PatientsListPage';
|
||||
@@ -283,6 +284,7 @@ export default function App() {
|
||||
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
|
||||
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
|
||||
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
|
||||
<Route path="record-number-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['patients', 'view']}><RecordNumberSettingsPage /></RoleRoute>} />
|
||||
<Route path="appointment-settings" element={<RoleRoute roles={['doctor', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><AppointmentSettingsPage /></RoleRoute>} />
|
||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><SubscriptionPage /></RoleRoute>} />
|
||||
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['discounts', 'view']}><DiscountsPage /></RoleRoute>} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
|
||||
RectangleStackIcon,
|
||||
RectangleStackIcon, HashtagIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
|
||||
{ key: 'discounts', label: 'مدیریت تخفیفها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
|
||||
{ key: 'tags', label: 'برچسبها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] },
|
||||
{ key: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] },
|
||||
{ key: 'sms', label: 'پیامکها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] },
|
||||
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
|
||||
export type RecordNumberResetPolicy = 'none' | 'yearly' | 'monthly';
|
||||
|
||||
export interface RecordNumberSettings {
|
||||
enabled: boolean;
|
||||
pattern: string;
|
||||
reset_policy: RecordNumberResetPolicy;
|
||||
counter: number;
|
||||
/** شمارهٔ بعدی، بدون مصرفکردن شمارنده. */
|
||||
next_preview: string;
|
||||
/** فقط صاحب مجموعه (و ادمین) اجازهٔ ذخیره دارد؛ گیت اصلی سمت سرور است. */
|
||||
can_edit: boolean;
|
||||
}
|
||||
|
||||
const KEY = ['record-number-settings'];
|
||||
|
||||
/**
|
||||
* الگوی شمارهٔ پروندهٔ محیط جاری.
|
||||
*
|
||||
* هم صفحهٔ تنظیمات از آن میخواند، هم فرم پرونده — فرم باید بداند شماره خودکار
|
||||
* ساخته میشود یا هنوز دستی است.
|
||||
*/
|
||||
export function useRecordNumberSettings() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => api.get<ApiResponse<RecordNumberSettings>>('/api/v1/patient-record-number-settings'),
|
||||
staleTime: 60_000,
|
||||
// منشیِ بدون مجوز `patients.view` اینجا ۴۰۳ میگیرد؛ تلاش دوباره چیزی عوض نمیکند.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (d: Pick<RecordNumberSettings, 'enabled' | 'pattern' | 'reset_policy'>) =>
|
||||
api.put<ApiResponse<RecordNumberSettings>>('/api/v1/patient-record-number-settings', d),
|
||||
onSuccess: () => {
|
||||
toast.success('الگوی شماره پرونده ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ الگو ناموفق بود'),
|
||||
});
|
||||
|
||||
return { settings: query.data?.data ?? null, loading: query.isLoading, save };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import PatientRecordFormPage from './PatientRecordFormPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
/** الگوی شمارهٔ پرونده؛ `enabled=false` یعنی رفتار قدیمیِ ورود دستی. */
|
||||
function mockPattern(settings: Record<string, unknown>) {
|
||||
get.mockImplementation((url: string) =>
|
||||
url.includes('patient-record-number-settings')
|
||||
? Promise.resolve({ success: true, data: { pattern: '{YY}-{SEQ:4}', reset_policy: 'none', counter: 0, next_preview: '05-0001', ...settings } })
|
||||
: Promise.resolve({ success: true, data: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکردن فیلدهای الزامیِ دیگر تا فقط شمارهٔ پرونده متغیر بماند.
|
||||
*
|
||||
* اول منتظر رسیدن تنظیمات میماند: رندرِ بعد از پاسخ، منوی باز react-select را
|
||||
* میبندد و انتخاب جنسیت را میخورد.
|
||||
*/
|
||||
async function fillRequiredFields() {
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith(expect.stringContaining('patient-record-number-settings')));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /ثبت|ذخیره/ })).toBeEnabled());
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'علی محمدی' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '0012345675' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09121234567' } });
|
||||
const gender = document.getElementById('patient-gender-select') as HTMLInputElement;
|
||||
fireEvent.focus(gender);
|
||||
fireEvent.keyDown(gender, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText('مرد'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'rec-new' } });
|
||||
(api.patch as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('PatientRecordFormPage — شمارهٔ پرونده', () => {
|
||||
/** ⚠️ مرزی: بدون الگو، فیلد الزامی میماند (رفتار امروز). */
|
||||
it('بدون الگو شماره پرونده الزامی است', async () => {
|
||||
mockPattern({ enabled: false, can_edit: true });
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
|
||||
await fillRequiredFields();
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات') ?? screen.getByRole('button', { name: /ثبت|ذخیره/ }));
|
||||
|
||||
expect(await screen.findByText('شماره پرونده الزامی است')).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** ✅ موفق: با الگوی فعال و بدون اجازهٔ دستی، فیلد read-only است و ارسال نمیشود. */
|
||||
it('با الگوی فعال و بدون can_edit، شماره فرستاده نمیشود', async () => {
|
||||
mockPattern({ enabled: true, can_edit: false });
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
|
||||
expect(await screen.findByText('شماره از الگوی مجموعه ساخته میشود')).toBeInTheDocument();
|
||||
|
||||
await fillRequiredFields();
|
||||
fireEvent.click(screen.getByRole('button', { name: /ثبت|ذخیره/ }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1]).not.toHaveProperty('record_number');
|
||||
});
|
||||
|
||||
/** ✅ موفق: صاحب مجموعه میتواند خالی بگذارد تا سرور بسازد. */
|
||||
it('صاحب مجموعه میتواند شماره را خالی بگذارد', async () => {
|
||||
mockPattern({ enabled: true, can_edit: true });
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
|
||||
expect(await screen.findByText(/خالی بماند تا از الگو ساخته شود/)).toBeInTheDocument();
|
||||
|
||||
await fillRequiredFields();
|
||||
fireEvent.click(screen.getByRole('button', { name: /ثبت|ذخیره/ }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1].record_number).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -11,21 +11,30 @@ vi.mock('../lib/api', () => ({
|
||||
import { api } from '../lib/api';
|
||||
import PatientRecordFormPage from './PatientRecordFormPage';
|
||||
|
||||
/** react-select (SearchableSelect) را با placeholder پیدا و گزینه را با متن انتخاب میکند. */
|
||||
async function pickSelect(placeholder: string, optionLabel: string) {
|
||||
const ph = await screen.findByText(placeholder);
|
||||
const control = ph.closest('div[class*="control"]') as HTMLElement;
|
||||
const input = control.querySelector('input') as HTMLInputElement;
|
||||
/** جنسیت را از SearchableSelect انتخاب میکند (شناسهٔ ورودی، نه placeholder). */
|
||||
async function pickGender(optionLabel: string) {
|
||||
// فلاشِ رندرهای در صف (کوئریِ تنظیمات)؛ رندرِ وسط تعامل، منوی react-select را میبندد.
|
||||
await waitFor(() => expect(document.getElementById('patient-gender-select')).toBeInTheDocument());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const input = document.getElementById('patient-gender-select') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText(optionLabel));
|
||||
}
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset(); patch.mockReset();
|
||||
get.mockReset(); post.mockReset(); patch.mockReset();
|
||||
// صفحه الگوی شمارهٔ پرونده را میخواند؛ بدون mock، ردِ همان کوئری وسط تعامل
|
||||
// یک رندر میآورد و منوی باز react-select را میبندد.
|
||||
get.mockResolvedValue({
|
||||
success: true,
|
||||
data: { enabled: false, pattern: '{YY}-{SEQ:4}', reset_policy: 'none', counter: 0, next_preview: '05-0001', can_edit: true },
|
||||
});
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'new-1' } });
|
||||
patch.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
@@ -33,10 +42,12 @@ beforeEach(() => {
|
||||
describe('PatientRecordFormPage (تشکیل پرونده)', () => {
|
||||
it('creates a record then patches the demographic fields', async () => {
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
// اول تنظیمات الگو برسد؛ رندرِ بعد از پاسخ، منوی باز react-select را میبندد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'بیمار نمونه' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1001' } });
|
||||
await pickSelect('انتخاب...', 'زن'); // gender = female
|
||||
await pickGender('زن'); // gender = female
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '1234567890' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||
@@ -49,10 +60,12 @@ describe('PatientRecordFormPage (تشکیل پرونده)', () => {
|
||||
|
||||
it('blocks submit and shows a validation error for a bad national code', async () => {
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
// اول تنظیمات الگو برسد؛ رندرِ بعد از پاسخ، منوی باز react-select را میبندد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'ب' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1' } });
|
||||
await pickSelect('انتخاب...', 'مرد');
|
||||
await pickGender('مرد');
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '12' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -14,12 +14,15 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { numericField } from '../lib/forms';
|
||||
import BackButton from '../components/ui/BackButton';
|
||||
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
|
||||
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
||||
|
||||
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
|
||||
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
|
||||
// الزامیبودنش شرطی است: با الگوی فعال، سرور شماره را میسازد و این فیلد اصلاً
|
||||
// فرستاده نمیشود. اعتبارسنجیِ شرطی در `superRefine` پایینتر است.
|
||||
record_number: z.string(),
|
||||
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
|
||||
national_code: iranNationalCodeSchema,
|
||||
mobile: iranMobileSchema,
|
||||
@@ -39,8 +42,25 @@ export default function PatientRecordFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { settings } = useRecordNumberSettings();
|
||||
/** الگو روشن است ⇒ شماره را سرور میسازد. */
|
||||
const autoNumbered = !!settings?.enabled;
|
||||
/** ورود دستی: وقتی الگویی نیست، یا کاربر صاحب مجموعه است (سرور هم همین را میسنجد). */
|
||||
const manualAllowed = !autoNumbered || !!settings?.can_edit;
|
||||
|
||||
const formSchema = useMemo(
|
||||
() => schema.superRefine((v, ctx) => {
|
||||
// فقط وقتی شماره دستی است الزامی میماند؛ وگرنه کاربر با فیلدی که نمیتواند
|
||||
// پرش کند پشت فرمِ قفلشده میماند.
|
||||
if (!autoNumbered && !(v.record_number ?? '').trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['record_number'], message: 'شماره پرونده الزامی است' });
|
||||
}
|
||||
}),
|
||||
[autoNumbered],
|
||||
);
|
||||
|
||||
const form = useForm<Form>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' },
|
||||
});
|
||||
|
||||
@@ -74,14 +94,18 @@ export default function PatientRecordFormPage() {
|
||||
referral_source: d.referral_source || null,
|
||||
description: d.description || null,
|
||||
};
|
||||
// شمارهٔ پرونده فقط وقتی فرستاده میشود که ورود دستی مجاز باشد؛ وگرنه سرور
|
||||
// ۴۰۳ میدهد و سرورست که شماره را از الگو میسازد.
|
||||
const numberPayload = manualAllowed ? { record_number: d.record_number ?? '' } : {};
|
||||
|
||||
if (isEdit) {
|
||||
return api.patch(`/api/v1/patient/${uuid}`, {
|
||||
name: d.name, national_code: d.national_code, mobile: d.mobile, record_number: d.record_number, ...profilePayload,
|
||||
name: d.name, national_code: d.national_code, mobile: d.mobile, ...numberPayload, ...profilePayload,
|
||||
});
|
||||
}
|
||||
// create: POST creates the record + identity, then PATCH applies the profile demographics
|
||||
const created = await api.post<ApiResponse<PatientRecord>>('/api/v1/patient', {
|
||||
name: d.name, mobile: d.mobile, national_code: d.national_code, record_number: d.record_number,
|
||||
name: d.name, mobile: d.mobile, national_code: d.national_code, ...numberPayload,
|
||||
});
|
||||
const newUuid = (created as any)?.data?.uuid;
|
||||
if (newUuid) await api.patch(`/api/v1/patient/${newUuid}`, profilePayload);
|
||||
@@ -116,11 +140,34 @@ export default function PatientRecordFormPage() {
|
||||
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
|
||||
<div className="field"><input {...form.register('name')} placeholder="نام و نام خانوادگی را وارد نمایید" /></div>
|
||||
</Field>
|
||||
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
|
||||
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
|
||||
<Field
|
||||
label="شماره پرونده"
|
||||
required={!autoNumbered}
|
||||
error={form.formState.errors.record_number?.message}
|
||||
>
|
||||
{manualAllowed ? (
|
||||
<div className="field">
|
||||
<input
|
||||
{...form.register('record_number')}
|
||||
placeholder={autoNumbered ? (settings?.next_preview ?? 'شماره پرونده') : 'شماره پرونده'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field" style={{ opacity: 0.7 }}>
|
||||
<input value={isEdit ? (recordData?.data?.record_number ?? '') : (settings?.next_preview ?? '')} readOnly dir="ltr" />
|
||||
</div>
|
||||
)}
|
||||
{autoNumbered && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'block', marginTop: 4 }}>
|
||||
{manualAllowed
|
||||
? `خالی بماند تا از الگو ساخته شود (شمارهٔ بعدی: ${settings?.next_preview ?? '—'})`
|
||||
: 'شماره از الگوی مجموعه ساخته میشود'}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
|
||||
<SearchableSelect
|
||||
inputId="patient-gender-select"
|
||||
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
|
||||
value={form.watch('gender') ?? null}
|
||||
onChange={(v) => form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import RecordNumberSettingsPage from './RecordNumberSettingsPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const put = api.put as ReturnType<typeof vi.fn>;
|
||||
|
||||
function mockSettings(overrides: Record<string, unknown> = {}) {
|
||||
get.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: false, pattern: '{YY}-{SEQ:4}', reset_policy: 'none',
|
||||
counter: 0, next_preview: '05-0001', can_edit: true, ...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
put.mockReset();
|
||||
put.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('RecordNumberSettingsPage', () => {
|
||||
it('الگو و پیشنمایش سرور را نشان میدهد', async () => {
|
||||
mockSettings({ enabled: true, pattern: 'MD-{YY}-{SEQ:4}', reset_policy: 'yearly', counter: 12, next_preview: 'MD-05-0013' });
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
expect(await screen.findByDisplayValue('MD-{YY}-{SEQ:4}')).toBeInTheDocument();
|
||||
expect(screen.getByText('MD-05-0013')).toBeInTheDocument();
|
||||
expect(screen.getByText(/12 پرونده را شمارهگذاری کرده/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** ✅ موفق: تغییر الگو و ذخیره با همان سه فیلد. */
|
||||
it('الگوی تغییریافته را ذخیره میکند', async () => {
|
||||
mockSettings();
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
const input = await screen.findByLabelText('الگوی شماره');
|
||||
fireEvent.change(input, { target: { value: 'P-{SEQ:3}' } });
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'شمارهگذاری خودکار پرونده' }));
|
||||
|
||||
fireEvent.click(screen.getByText('ذخیره تنظیمات'));
|
||||
await waitFor(() => expect(put).toHaveBeenCalled());
|
||||
|
||||
expect(put.mock.calls[0][0]).toBe('/api/v1/patient-record-number-settings');
|
||||
expect(put.mock.calls[0][1]).toEqual({ enabled: true, pattern: 'P-{SEQ:3}', reset_policy: 'none' });
|
||||
});
|
||||
|
||||
/** ⚠️ مرزی: بدون تغییر، دکمهٔ ذخیره غیرفعال است. */
|
||||
it('تا تغییری نباشد دکمهٔ ذخیره غیرفعال است', async () => {
|
||||
mockSettings();
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
const btn = await screen.findByText('ذخیره تنظیمات');
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
/** ❌ خطا: کاربری که صاحب مجموعه نیست فقط میبیند. */
|
||||
it('بدون can_edit فرم فقط خواندنی است', async () => {
|
||||
mockSettings({ can_edit: false, enabled: true });
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
expect(await screen.findByText(/تغییر الگو فقط با حساب صاحب مجموعه/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('الگوی شماره')).toHaveAttribute('readonly');
|
||||
expect(screen.queryByText('ذخیره تنظیمات')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
||||
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
|
||||
|
||||
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
|
||||
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو میرود' },
|
||||
{ value: 'yearly', label: 'هر سال شمسی از ۱ شروع شود' },
|
||||
{ value: 'monthly', label: 'هر ماه شمسی از ۱ شروع شود' },
|
||||
];
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
['{SEQ}', 'شمارنده — {SEQ:4} یعنی چهار رقمی با صفرِ ابتدایی'],
|
||||
['{YY}', 'دو رقم آخر سال شمسی'],
|
||||
['{YYYY}', 'سال شمسی کامل'],
|
||||
['{MM}', 'ماه شمسی دو رقمی'],
|
||||
];
|
||||
|
||||
/**
|
||||
* شمارهٔ پرونده — الگوی شمارهگذاری خودکار پروندههای همین مجموعه.
|
||||
*
|
||||
* پیشنمایش سمت کلاینت ساخته نمیشود: همان `next_preview` سرور نشان داده میشود تا
|
||||
* دو پیادهسازیِ رندر (اینجا و در `RecordNumberGenerator`) از هم واگرا نشوند.
|
||||
*/
|
||||
export default function RecordNumberSettingsPage() {
|
||||
const { settings, loading, save } = useRecordNumberSettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pattern, setPattern] = useState('');
|
||||
const [resetPolicy, setResetPolicy] = useState<RecordNumberResetPolicy>('none');
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setEnabled(settings.enabled);
|
||||
setPattern(settings.pattern);
|
||||
setResetPolicy(settings.reset_policy);
|
||||
}, [settings]);
|
||||
|
||||
const readOnly = !settings?.can_edit;
|
||||
const dirty = !!settings && (
|
||||
enabled !== settings.enabled || pattern !== settings.pattern || resetPolicy !== settings.reset_policy
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsLayout active="record-number">
|
||||
<div
|
||||
style={{ background: 'var(--surface)', minWidth: 0 }}
|
||||
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
||||
>
|
||||
<h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1>
|
||||
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
|
||||
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه — چه از فرم تشکیل پرونده و چه از
|
||||
نوبتی که قطعی میشود — بر اساس همین الگو ساخته میشود.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
|
||||
) : !settings ? (
|
||||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 13.5 }}>
|
||||
دسترسی به تنظیمات شمارهٔ پرونده ندارید.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{readOnly && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--warning-bg)', color: 'var(--text-2)', fontSize: 12.5,
|
||||
}}>
|
||||
تغییر الگو فقط با حساب صاحب مجموعه ممکن است؛ اینجا فقط مقدار فعلی را میبینید.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: readOnly ? 'not-allowed' : 'pointer' }}>
|
||||
<span style={{
|
||||
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
|
||||
background: enabled ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
|
||||
opacity: readOnly ? 0.6 : 1,
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="شمارهگذاری خودکار پرونده"
|
||||
checked={enabled}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'inherit' }}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, insetInlineStart: enabled ? 22 : 2, width: 18, height: 18,
|
||||
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
||||
}} />
|
||||
</span>
|
||||
شمارهگذاری خودکار پرونده
|
||||
</label>
|
||||
|
||||
<div style={{ maxWidth: 420, marginTop: 18 }}>
|
||||
<label className="field-label" htmlFor="record-number-pattern">الگوی شماره</label>
|
||||
<div className="field" style={{ marginTop: 6 }}>
|
||||
<input
|
||||
id="record-number-pattern"
|
||||
value={pattern}
|
||||
dir="ltr"
|
||||
readOnly={readOnly}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
placeholder="MD-{YY}-{SEQ:4}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul style={{ margin: '10px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{TOKENS.map(([token, hint]) => (
|
||||
<li key={token} style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
<code dir="ltr" style={{ color: 'var(--primary)' }}>{token}</code> — {hint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 420, marginTop: 18 }}>
|
||||
<label className="field-label" id="reset-policy-label">ریست شمارنده</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={RESET_OPTIONS}
|
||||
value={resetPolicy}
|
||||
onChange={(v) => setResetPolicy((v ?? 'none') as RecordNumberResetPolicy)}
|
||||
ariaLabelledBy="reset-policy-label"
|
||||
isDisabled={readOnly}
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 18, padding: '12px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)', maxWidth: 420,
|
||||
}}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>شمارهٔ بعدی: </span>
|
||||
<b dir="ltr" style={{ fontSize: 14, color: 'var(--text)' }}>{settings.next_preview}</b>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
|
||||
{dirty
|
||||
? 'پیشنمایش پس از ذخیره بهروز میشود.'
|
||||
: `شمارنده تا اینجا ${settings.counter} پرونده را شمارهگذاری کرده است.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 22 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!dirty || save.isPending}
|
||||
onClick={() => save.mutate({ enabled, pattern, reset_policy: resetPolicy })}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره…' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
+80
-1
@@ -117,6 +117,69 @@ restriction.
|
||||
|
||||
---
|
||||
|
||||
### الگوی شمارهٔ پرونده (2026-08)
|
||||
|
||||
شمارهٔ پرونده میتواند بهجای ورود دستی، از یک الگوی per-tenant تولید شود. الگو و
|
||||
شمارندهاش روی `record_number_patterns` مینشینند — یک ردیف به ازای هر محیط.
|
||||
|
||||
#### `GET /api/v1/patient-record-number-settings`
|
||||
|
||||
**دسترسی:** `patients.view` (منشی/پزشکِ عضو با همان مجوز). محیط از `UserActiveContext`
|
||||
حل میشود؛ محیطِ حلنشده → `403 ERR_FORBIDDEN_001`.
|
||||
|
||||
خروجی واقعی (محیطی که هنوز چیزی ذخیره نکرده — همهچیز پیشفرض):
|
||||
|
||||
```json
|
||||
{"success":true,"data":{"enabled":false,"pattern":"{YY}-{SEQ:4}","reset_policy":"none","counter":0,"next_preview":"05-0001","can_edit":true}}
|
||||
```
|
||||
|
||||
| فیلد | معنی |
|
||||
|---|---|
|
||||
| `enabled` | تولید خودکار روشن است یا نه. خاموش = رفتار قدیمی (ورود دستی برای همه) |
|
||||
| `pattern` | الگو با توکنهای `{YYYY}` `{YY}` `{MM}` `{SEQ}` `{SEQ:n}` |
|
||||
| `reset_policy` | `none` \| `yearly` \| `monthly` — بر مبنای تقویم **شمسی** |
|
||||
| `counter` | شمارندهٔ فعلیِ همین دوره |
|
||||
| `next_preview` | شمارهٔ بعدی، بدون مصرفکردن شمارنده |
|
||||
| `can_edit` | آیا همین کاربر اجازهٔ `PUT` دارد (صاحب محیط یا ادمین) |
|
||||
|
||||
#### `PUT /api/v1/patient-record-number-settings`
|
||||
|
||||
**دسترسی:** فقط **صاحب محیط** (مالک کلینیک، یا پزشک در مطب شخصی خودش) و `ROLE_ADMIN`.
|
||||
پزشکِ مهمانِ کلینیک و منشی — حتی با `patients.update` — رد میشوند: شمارهٔ پرونده
|
||||
قرارداد ثبتِ کل مجموعه است.
|
||||
|
||||
| فیلد | نوع | الزامی | قاعده |
|
||||
|---|---|---|---|
|
||||
| `enabled` | bool | — | پیشفرض `false` |
|
||||
| `pattern` | string | ✅ | حداکثر ۶۰ نویسه، فقط توکنهای مجاز، حتماً شامل `{SEQ...}` |
|
||||
| `reset_policy` | string | — | `none` \| `yearly` \| `monthly`، پیشفرض `none` |
|
||||
|
||||
خروجی واقعی:
|
||||
|
||||
```json
|
||||
{"success":true,"data":{"enabled":true,"pattern":"MD-{YY}-{SEQ:4}","reset_policy":"yearly","counter":0,"next_preview":"MD-05-0001","can_edit":true}}
|
||||
```
|
||||
|
||||
**Errors** (پیامها واقعیاند — از اجرای همین اندپوینت):
|
||||
|
||||
| Code | HTTP | field | شرط |
|
||||
|------|------|-------|-----|
|
||||
| `ERR_FORBIDDEN_001` | 403 | — | «تغییر الگوی شماره پرونده فقط با حساب صاحب مجموعه ممکن است» |
|
||||
| `ERR_VALIDATION_002` | 422 | `pattern` | «توکن ناشناخته: {FOO} — مجاز: {YYYY}، {YY}، {MM}، {SEQ} یا {SEQ:n}» |
|
||||
| `ERR_VALIDATION_002` | 422 | `pattern` | «الگو باید {SEQ} یا {SEQ:n} داشته باشد، وگرنه همهٔ پروندهها یک شماره میگیرند» |
|
||||
| `ERR_VALIDATION_002` | 422 | `pattern` | «وقتی سال در الگو هست، ریست شمارنده باید سالانه یا ماهانه باشد» |
|
||||
| `ERR_VALIDATION_002` | 422 | `pattern` | «وقتی {MM} در الگو هست، ریست شمارنده باید ماهانه باشد» |
|
||||
| `ERR_VALIDATION_002` | 422 | `reset_policy` | «سیاست ریست شمارنده نامعتبر است» |
|
||||
| `ERR_VALIDATION_001` | 422 | — | بدنهٔ غیر-JSON |
|
||||
|
||||
- **تغییر الگو شمارنده را صفر نمیکند.** شمارههای صادرشده وجود دارند و شروع دوبارهٔ
|
||||
دنباله مستقیم به `uniq_patient_record_number` میخورد. صفرشدن فقط با ورود به دورهٔ
|
||||
تازه (سال/ماه شمسی) رخ میدهد.
|
||||
- **الگوی خاموش هم اعتبارسنجی میشود** تا خطای الگو سرِ فرمِ تنظیمات دیده شود، نه
|
||||
روزی که کاربر روشنش میکند و ساختِ پرونده میشکند.
|
||||
|
||||
---
|
||||
|
||||
### Create Patient Record
|
||||
|
||||
```
|
||||
@@ -138,13 +201,24 @@ Creates a patient record for a user under the current entity. If the record alre
|
||||
"mobile": "09xxxxxxxxx (اختیاری — برای جستجو یا ساخت بیمار جدید)",
|
||||
"name": "string (الزامی فقط هنگام ساخت بیمار جدید)",
|
||||
"national_code": "string (اختیاری، ۱۰ رقم)",
|
||||
"record_number": "string (اختیاری) — شماره پرونده، مخصوص رکورد",
|
||||
"record_number": "string (اختیاری) — شماره پرونده، مخصوص رکورد. با الگوی فعال فقط از صاحب مجموعه پذیرفته میشود",
|
||||
"tags": ["uuid برچسبهای TenantTag (اختیاری) — باید متعلق به همین tenant باشند"]
|
||||
}
|
||||
```
|
||||
|
||||
- اگر `user_uuid` و `mobile` هر دو خالی باشند → خطا.
|
||||
- `record_number` و `tags` روی خودِ رکورد ذخیره میشوند (نه پروفایل کاربر). سایر مشخصات دموگرافیک (`gender`, `date_of_birth`, `referral_source`, `description`, بیمهها) روی `UserProfile` هستند و از طریق `PATCH /patient/{uuid}` ست میشوند. پاسخ همیشه `record_number` و `tags: [{uuid,name,color}]` را برمیگرداند.
|
||||
- **شمارهٔ پرونده با الگوی فعال (2026-08):** اگر محیط الگوی فعال داشته باشد و `record_number`
|
||||
فرستاده **نشود**، سرور شمارهٔ بعدیِ همان الگو را میسازد و در پاسخ برمیگرداند
|
||||
(مثلاً `"record_number": "MD-0001"`). فرستادنِ `record_number` توسط کسی جز صاحب
|
||||
مجموعه → `403 ERR_FORBIDDEN_001` با `field: record_number` و پیام «ثبت دستی شماره
|
||||
پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته میشود». بدون الگوی فعال، رفتار قبلی
|
||||
برقرار است: شماره دستی است و همه میتوانند بفرستند. همین قاعده روی
|
||||
`PATCH /patient/{uuid}` هم اعمال میشود. تنظیم الگو:
|
||||
[الگوی شمارهٔ پرونده](#الگوی-شمارهٔ-پرونده-2026-08).
|
||||
- **پروندهٔ خودکارِ نوبت هم شماره میگیرد:** پروندهای که هنگام قطعیشدن نوبت ساخته
|
||||
میشود (`PatientService::autoCreateOnAppointmentConfirm`) از همان دنباله شماره
|
||||
میگیرد؛ پیش از این همیشه `null` بود.
|
||||
- برچسب متعلق به tenant دیگر → `422 ERR_VALIDATION_001` (`field: tags`).
|
||||
- `national_code` فقط وقتی روی کاربر ست میشود که کاربر کد ملی نداشته باشد.
|
||||
- موبایل تکراری duplicate نمیسازد؛ همان کاربر استفاده میشود.
|
||||
@@ -185,6 +259,11 @@ GET /api/v1/patient/{uuid}
|
||||
|
||||
Returns a single patient record, enriched with the patient's full profile (`profile`) درونخطی از `UserProfile`. اگر پروفایل وجود نداشت، فیلدها `null` برمیگردند (نه خطا). نام بیمهها از روی id resolve میشوند.
|
||||
|
||||
> **اثر جانبیِ عمدی (2026-08):** اگر این پرونده `record_number = null` باشد و محیط الگوی
|
||||
> فعال داشته باشد، همین `GET` شماره را تخصیص میدهد و ذخیره میکند — یعنی پروندههای
|
||||
> ساختهشده پیش از الگو، اولین بار که باز میشوند شمارهدار میشوند. فراخوانی دوم چیزی
|
||||
> نمینویسد و همان شماره را برمیگرداند. بدون الگوی فعال، این `GET` فقط میخواند.
|
||||
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* الگوی شمارهٔ پروندهٔ هر محیط + شمارندهٔ همان الگو.
|
||||
*
|
||||
* `diff` دو تغییر بیربط را هم برداشته بود (جدول `messenger_messages` که خودِ
|
||||
* transport میسازد، و rename یک ایندکس روی `clinic_resources` که drift قبلی است)؛
|
||||
* هر دو حذف شدند تا این migration فقط همین قابلیت را حمل کند.
|
||||
*/
|
||||
final class Version20260804082035 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Per-tenant patient record number pattern with its own counter';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE record_number_patterns (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
entity_type VARCHAR(10) NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
enabled TINYINT DEFAULT 0 NOT NULL,
|
||||
pattern VARCHAR(60) NOT NULL,
|
||||
reset_policy VARCHAR(10) DEFAULT 'none' NOT NULL,
|
||||
counter INT DEFAULT 0 NOT NULL,
|
||||
counter_period VARCHAR(7) DEFAULT NULL,
|
||||
updated_at INT NOT NULL,
|
||||
UNIQUE INDEX uniq_record_number_pattern (entity_type, entity_id),
|
||||
PRIMARY KEY (id)
|
||||
) DEFAULT CHARACTER SET utf8mb4
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE record_number_patterns');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* یکتایی شمارهٔ پرونده در هر محیط.
|
||||
*
|
||||
* پیش از اجرا روی دادهٔ واقعی بررسی شد که شمارهٔ تکراری وجود ندارد. چند `NULL` در
|
||||
* unique index مجاز است، پس پروندههای بیشماره (که تا پیش از این قابلیت اکثریتاند)
|
||||
* مانع نمیشوند.
|
||||
*/
|
||||
final class Version20260804082400 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Unique patient record number per tenant';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_patient_record_number ON patient_records (entity_type, entity_id, record_number)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX uniq_patient_record_number ON patient_records');
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ class PatientController extends BaseController
|
||||
private readonly \App\Patient\Repository\SessionAuditLogRepository $sessionAuditRepo,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
private readonly \App\Patient\Service\RecordNumberGenerator $recordNumbers,
|
||||
private readonly \App\Patient\Repository\RecordNumberPatternRepository $recordNumberPatterns,
|
||||
private readonly \App\Shared\Context\EntityContextResolver $contextResolver,
|
||||
) {}
|
||||
|
||||
// ── Financials (مالی: پرداخت / تراکنش / کیفپول) ────────────────────────────
|
||||
@@ -783,9 +786,17 @@ class PatientController extends BaseController
|
||||
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
|
||||
|
||||
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
|
||||
$record->setRecordNumber($rn);
|
||||
// شمارهٔ دستی فقط از صاحب مجموعه پذیرفته میشود؛ بقیه شمارهٔ الگو را میگیرند.
|
||||
// در نبودِ الگوی فعال، `next()` مقدار null میدهد و رفتار قدیمی (ورود دستی
|
||||
// برای همه) دستنخورده میماند.
|
||||
$manualNumber = trim((string) ($data['record_number'] ?? ''));
|
||||
if ($manualNumber !== '' && !$this->canSetRecordNumberManually($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'ثبت دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته میشود', 403, 'record_number');
|
||||
}
|
||||
|
||||
$record->setRecordNumber(
|
||||
$manualNumber !== '' ? $manualNumber : $this->recordNumbers->next($entityType, $entityId),
|
||||
);
|
||||
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
|
||||
if ($tagError !== null) {
|
||||
return $tagError;
|
||||
@@ -807,12 +818,39 @@ class PatientController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$this->backfillRecordNumber($record, $entityType, $entityId);
|
||||
|
||||
$data = $record->toArray();
|
||||
$data['profile'] = $this->buildPatientProfile($record->getUser());
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* پروندههای ساختهشده پیش از الگو، اولین بار که باز میشوند شماره میگیرند.
|
||||
*
|
||||
* اثر جانبیِ عمدی روی یک `GET` است: جایگزینش یا backfill دستهای بود (که ترتیب
|
||||
* شمارهها را به ترتیب `id` گره میزد) یا رها کردن پروندههای قدیمی بیشماره.
|
||||
* بار دوم چون شماره پر است هیچ نوشتنی رخ نمیدهد، و بدون الگوی فعال `next()`
|
||||
* مقدار `null` میدهد و این متد بیاثر است.
|
||||
*
|
||||
* دو `GET` همزمان روی یک پروندهٔ بیشماره دو شماره میگیرند و آخری برنده است؛
|
||||
* یعنی نهایتاً یک شماره هدر میرود. قفلکردنِ خودِ رکورد برای همین، هزینهاش از
|
||||
* فایدهاش بیشتر بود.
|
||||
*/
|
||||
private function backfillRecordNumber(PatientRecord $record, string $entityType, ?int $entityId): void
|
||||
{
|
||||
if ($record->getRecordNumber() !== null || $entityId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$number = $this->recordNumbers->next($entityType, $entityId);
|
||||
if ($number !== null) {
|
||||
$record->setRecordNumber($number);
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
@@ -933,6 +971,9 @@ class PatientController extends BaseController
|
||||
$this->profileRepo->save($profile);
|
||||
|
||||
if (array_key_exists('record_number', $data)) {
|
||||
if (!$this->canSetRecordNumberManually($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'تغییر دستی شماره پرونده مجاز نیست؛ شماره از الگوی مجموعه ساخته میشود', 403, 'record_number');
|
||||
}
|
||||
$rn = trim((string) ($data['record_number'] ?? ''));
|
||||
$record->setRecordNumber($rn === '' ? null : $rn);
|
||||
}
|
||||
@@ -1252,6 +1293,23 @@ class PatientController extends BaseController
|
||||
return $this->scope($user)->toLegacyTuple();
|
||||
}
|
||||
|
||||
/**
|
||||
* ورود دستیِ شمارهٔ پرونده.
|
||||
*
|
||||
* تا وقتی الگویی فعال نشده، همهچیز مثل قبل است و شماره دستی وارد میشود. با فعال
|
||||
* شدن الگو، شماره قراردادِ کلِ مجموعه میشود و فقط صاحبش میتواند خارج از دنباله
|
||||
* شماره بگذارد (مثلاً پروندهٔ کاغذیِ قدیمی).
|
||||
*/
|
||||
private function canSetRecordNumberManually(User $user, string $entityType, ?int $entityId): bool
|
||||
{
|
||||
$pattern = $this->recordNumberPatterns->findForPair($entityType, (int) $entityId);
|
||||
if ($pattern === null || !$pattern->isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->contextResolver->owns($user, $entityType, $entityId);
|
||||
}
|
||||
|
||||
private function assertPatientGate(string $entityType, ?int $entityId): void
|
||||
{
|
||||
if ($entityId === null) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Patient\Repository\RecordNumberPatternRepository;
|
||||
use App\Patient\Service\RecordNumberGenerator;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* الگوی شمارهٔ پروندهٔ محیط جاری.
|
||||
*
|
||||
* خواندن برای هر کسی که بیماران را میبیند (فرم پرونده باید بداند شماره خودکار
|
||||
* میآید یا نه)، ولی نوشتن فقط برای **صاحب محیط**: شمارهٔ پرونده قراردادِ ثبتِ کل
|
||||
* مجموعه است و منشی نباید وسط کار دنباله را عوض کند.
|
||||
*/
|
||||
#[OA\Tag(name: 'Patient')]
|
||||
class RecordNumberSettingsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RecordNumberPatternRepository $patternRepo,
|
||||
private readonly RecordNumberGenerator $generator,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
) {}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/patient-record-number-settings',
|
||||
summary: 'الگوی شمارهٔ پروندهٔ محیط جاری',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [new OA\Response(response: 200, description: 'تنظیمات الگو')],
|
||||
)]
|
||||
#[Route('/api/v1/patient-record-number-settings', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function show(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->secretaryAccess->denyUnlessGranted($user, 'patients', 'view');
|
||||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'patients', 'view');
|
||||
|
||||
[$entityType, $entityId] = $this->requireEnvironment($user);
|
||||
$pattern = $this->patternRepo->findForPair($entityType, $entityId);
|
||||
|
||||
return $this->success($this->present($pattern, $user, $entityType, $entityId));
|
||||
}
|
||||
|
||||
#[OA\Put(
|
||||
path: '/api/v1/patient-record-number-settings',
|
||||
summary: 'ثبت الگوی شمارهٔ پرونده — فقط صاحب محیط',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'ذخیره شد'),
|
||||
new OA\Response(response: 403, description: 'فقط صاحب محیط'),
|
||||
new OA\Response(response: 422, description: 'الگوی نامعتبر'),
|
||||
],
|
||||
)]
|
||||
#[Route('/api/v1/patient-record-number-settings', methods: ['PUT'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function update(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->requireEnvironment($user);
|
||||
|
||||
if (!$this->contextResolver->owns($user, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'تغییر الگوی شماره پرونده فقط با حساب صاحب مجموعه ممکن است', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true);
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$resetPolicy = (string) ($data['reset_policy'] ?? RecordNumberPattern::RESET_NONE);
|
||||
if (!in_array($resetPolicy, RecordNumberPattern::RESET_POLICIES, true)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'سیاست ریست شمارنده نامعتبر است', 422, 'reset_policy');
|
||||
}
|
||||
|
||||
$patternText = trim((string) ($data['pattern'] ?? ''));
|
||||
$enabled = (bool) ($data['enabled'] ?? false);
|
||||
|
||||
// الگوی خاموش هم اعتبارسنجی میشود: ذخیرهٔ الگوی خرابِ خاموش یعنی روزی که
|
||||
// کاربر روشنش میکند، خطا سرِ ساختِ پرونده بیرون میزند نه سرِ فرم تنظیمات.
|
||||
$errors = $this->generator->validatePattern($patternText, $resetPolicy);
|
||||
if ($errors !== []) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, implode(' · ', $errors), 422, 'pattern');
|
||||
}
|
||||
|
||||
$pattern = $this->patternRepo->findForPair($entityType, $entityId)
|
||||
?? new RecordNumberPattern($entityType, $entityId);
|
||||
|
||||
// تغییر الگو شمارنده را صفر نمیکند: شمارههای صادرشده وجود دارند و شروع دوبارهٔ
|
||||
// دنباله، مستقیم به تداخل با آنها میخورد.
|
||||
$pattern->setPattern($patternText)->setResetPolicy($resetPolicy)->setEnabled($enabled);
|
||||
$this->patternRepo->save($pattern);
|
||||
|
||||
return $this->success($this->present($pattern, $user, $entityType, $entityId));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function present(?RecordNumberPattern $pattern, User $user, string $entityType, int $entityId): array
|
||||
{
|
||||
$patternText = $pattern?->getPattern() ?? RecordNumberPattern::DEFAULT_PATTERN;
|
||||
$counter = $pattern?->getCounter() ?? 0;
|
||||
|
||||
return [
|
||||
'enabled' => $pattern?->isEnabled() ?? false,
|
||||
'pattern' => $patternText,
|
||||
'reset_policy' => $pattern?->getResetPolicy() ?? RecordNumberPattern::RESET_NONE,
|
||||
'counter' => $counter,
|
||||
'next_preview' => $this->generator->preview($patternText, $counter),
|
||||
'can_edit' => $this->contextResolver->owns($user, $entityType, $entityId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int}
|
||||
* @throws \App\Shared\Exception\AppException وقتی محیط حل نشده (۴۰۳)
|
||||
*/
|
||||
private function requireEnvironment(User $user): array
|
||||
{
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
|
||||
[$type, $id] = $context->toEntityPair();
|
||||
|
||||
return [$type, (int) $id];
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\Entity(repositoryClass: PatientRecordRepository::class)]
|
||||
#[ORM\Table(name: 'patient_records')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
|
||||
// شمارهٔ پرونده در هر محیط یکتاست. این تور ایمنیِ شمارندهٔ RecordNumberPattern است،
|
||||
// نه مکانیزم اصلی؛ چند `NULL` را MariaDB میپذیرد، پس پروندههای بیشماره مانع نیستند.
|
||||
#[ORM\UniqueConstraint(name: 'uniq_patient_record_number', columns: ['entity_type', 'entity_id', 'record_number'])]
|
||||
class PatientRecord
|
||||
{
|
||||
#[ORM\Id]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Patient\Repository\RecordNumberPatternRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* الگوی شمارهٔ پروندهٔ یک محیط — یک ردیف به ازای هر مطب/کلینیک.
|
||||
*
|
||||
* شمارنده روی همین ردیف مینشیند، نه `MAX(record_number)+1`: شمارش از روی مقادیر
|
||||
* موجود با شمارهٔ دستیِ صاحب حساب یا با تغییر الگو میشکند.
|
||||
*
|
||||
* `counterPeriod` مهرِ دورهٔ فعلیِ شمارنده است (`1405` سالانه، `1405-05` ماهانه،
|
||||
* `null` بدون ریست). بدون این ستون، «آیا سال عوض شده؟» فقط با حدس از `updatedAt`
|
||||
* قابل جواب بود.
|
||||
*
|
||||
* جفت محیط را مثل {@see \App\Insurance\Entity\TenantServiceCategorySetting} خودش
|
||||
* اعلام میکند و `TenantOwnedTrait` نمیگیرد: آن trait برای `assignTenant()` یک
|
||||
* `EntityContext` میخواهد، ولی تولیدکنندهٔ شماره فقط جفت `(type, id)` را دارد.
|
||||
*/
|
||||
#[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';
|
||||
|
||||
public const RESET_POLICIES = [self::RESET_NONE, self::RESET_YEARLY, self::RESET_MONTHLY];
|
||||
|
||||
/** الگوی پیشفرضِ ردیفِ تازهساخته — تا فرمِ تنظیمات با فیلد خالی باز نشود. */
|
||||
public const DEFAULT_PATTERN = '{YY}-{SEQ:4}';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
private bool $enabled = false;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 60)]
|
||||
private string $pattern = self::DEFAULT_PATTERN;
|
||||
|
||||
#[ORM\Column(name: 'reset_policy', type: 'string', length: 10, options: ['default' => self::RESET_NONE])]
|
||||
private string $resetPolicy = self::RESET_NONE;
|
||||
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||
private int $counter = 0;
|
||||
|
||||
#[ORM\Column(name: 'counter_period', type: 'string', length: 7, nullable: true)]
|
||||
private ?string $counterPeriod = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId)
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function isEnabled(): bool { return $this->enabled; }
|
||||
public function getPattern(): string { return $this->pattern; }
|
||||
public function getResetPolicy(): string { return $this->resetPolicy; }
|
||||
public function getCounter(): int { return $this->counter; }
|
||||
public function getCounterPeriod(): ?string { return $this->counterPeriod; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setEnabled(bool $v): self { $this->enabled = $v; $this->touch(); return $this; }
|
||||
public function setPattern(string $v): self { $this->pattern = $v; $this->touch(); return $this; }
|
||||
|
||||
public function setResetPolicy(string $v): self
|
||||
{
|
||||
if (!in_array($v, self::RESET_POLICIES, true)) {
|
||||
throw new \InvalidArgumentException(sprintf('Unknown reset policy "%s".', $v));
|
||||
}
|
||||
$this->resetPolicy = $v;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** ورود به دورهٔ تازه: شمارنده از صفر شروع میشود. */
|
||||
public function resetCounter(?string $period): self
|
||||
{
|
||||
$this->counter = 0;
|
||||
$this->counterPeriod = $period;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function incrementCounter(): int
|
||||
{
|
||||
$this->counter++;
|
||||
$this->touch();
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
private function touch(): void
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<RecordNumberPattern>
|
||||
*/
|
||||
class RecordNumberPatternRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, RecordNumberPattern::class);
|
||||
}
|
||||
|
||||
public function findForPair(string $entityType, int $entityId): ?RecordNumberPattern
|
||||
{
|
||||
return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* همان ردیف، ولی با قفلِ نوشتن (`SELECT … FOR UPDATE`).
|
||||
*
|
||||
* شمارندهٔ بدون قفل، دو درخواستِ همزمان را به یک شماره میرساند: هر دو مقدار
|
||||
* قدیمی را میخوانند و همان را +۱ میکنند. فقط داخل تراکنش صدا زده میشود.
|
||||
*/
|
||||
public function lockForUpdate(string $entityType, int $entityId): ?RecordNumberPattern
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->where('p.entityType = :type')
|
||||
->andWhere('p.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->setLockMode(LockMode::PESSIMISTIC_WRITE)
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function save(RecordNumberPattern $entity, bool $flush = true): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
if ($flush) {
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Patient\Repository\SessionConsumableRepository;
|
||||
use App\Patient\Repository\SessionPaymentRepository;
|
||||
use App\Patient\Repository\SessionServiceRepository;
|
||||
use App\Patient\Service\RecordNumberGenerator;
|
||||
use App\Settlement\Service\WalletService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
@@ -58,6 +59,7 @@ class PatientService
|
||||
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
|
||||
private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo,
|
||||
private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership,
|
||||
private readonly RecordNumberGenerator $recordNumbers,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -209,6 +211,9 @@ class PatientService
|
||||
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
|
||||
if ($record === null) {
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $createdById);
|
||||
// پروندهٔ خودکار هم باید در همان دنبالهٔ شمارهها بنشیند؛ بیشترِ پروندهها
|
||||
// از همین مسیر ساخته میشوند، نه از فرم پنل.
|
||||
$record->setRecordNumber($this->recordNumbers->next($entityType, $entityId));
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Service;
|
||||
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Patient\Repository\RecordNumberPatternRepository;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها جایی که شمارهٔ پرونده ساخته میشود.
|
||||
*
|
||||
* سه مسئولیت جدا دارد: رندرِ خالصِ الگو، گرفتنِ شمارهٔ بعدی (تراکنشی)، و پیشنمایش
|
||||
* بدون مصرفِ شمارنده. هر سه مسیرِ ساختِ پرونده — فرم پنل، ساخت خودکار از نوبت، و
|
||||
* backfillِ پروندهٔ قدیمی — از همینجا شماره میگیرند تا منطق سهجا کپی نشود.
|
||||
*/
|
||||
class RecordNumberGenerator
|
||||
{
|
||||
/** توکنهای مجاز؛ هر چیز دیگری در اعتبارسنجی الگو رد میشود. */
|
||||
private const TOKEN_RE = '/\{(YYYY|YY|MM|SEQ(?::(\d+))?)\}/';
|
||||
|
||||
public function __construct(
|
||||
private readonly RecordNumberPatternRepository $repo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly JalaliDateService $jalali,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* شمارهٔ بعدیِ همین محیط، یا `null` وقتی الگویی تعریف/فعال نشده — یعنی «رفتار
|
||||
* امروز»: شماره دستی میماند.
|
||||
*
|
||||
* کل کار داخل یک تراکنش با قفلِ ردیفِ الگوست: خواندن-افزایش-نوشتنِ بدون قفل، دو
|
||||
* درخواستِ همزمان را به یک شماره میرساند.
|
||||
*/
|
||||
public function next(string $entityType, int $entityId): ?string
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($entityType, $entityId): ?string {
|
||||
$pattern = $this->repo->lockForUpdate($entityType, $entityId);
|
||||
if ($pattern === null || !$pattern->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$period = $this->periodKey($pattern->getResetPolicy());
|
||||
if ($pattern->getCounterPeriod() !== $period) {
|
||||
$pattern->resetCounter($period);
|
||||
}
|
||||
|
||||
return $this->render($pattern->getPattern(), $pattern->incrementCounter());
|
||||
});
|
||||
}
|
||||
|
||||
/** شمارهٔ بعدی بدون مصرفکردنش — برای فرمِ تنظیمات. */
|
||||
public function preview(string $pattern, int $currentCounter = 0): string
|
||||
{
|
||||
return $this->render($pattern, $currentCounter + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* جایگذاری توکنها. تاریخ شمسی از {@see JalaliDateService} میآید، نه از محاسبهٔ
|
||||
* دستی — همان فایل توضیح میدهد نسخهٔ دستسازِ قبلی ۱۶۰۱ سال خطا داشت.
|
||||
*/
|
||||
public function render(string $pattern, int $counter): string
|
||||
{
|
||||
[$jy, $jm] = $this->today();
|
||||
|
||||
return preg_replace_callback(
|
||||
self::TOKEN_RE,
|
||||
static function (array $m) use ($jy, $jm, $counter): string {
|
||||
$token = $m[1];
|
||||
|
||||
if ($token === 'YYYY') return (string) $jy;
|
||||
if ($token === 'YY') return substr((string) $jy, -2);
|
||||
if ($token === 'MM') return str_pad((string) $jm, 2, '0', STR_PAD_LEFT);
|
||||
|
||||
// {SEQ} یا {SEQ:n} — پدینگ سقف نیست: شمارندهٔ بلندتر از n بریده نمیشود.
|
||||
$width = isset($m[2]) && $m[2] !== '' ? (int) $m[2] : 1;
|
||||
|
||||
return str_pad((string) $counter, $width, '0', STR_PAD_LEFT);
|
||||
},
|
||||
$pattern,
|
||||
) ?? $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* خطاهای الگو بهصورت فهرست، تا فرم همه را یکجا نشان دهد.
|
||||
*
|
||||
* @return list<string> خالی یعنی معتبر
|
||||
*/
|
||||
public function validatePattern(string $pattern, string $resetPolicy): array
|
||||
{
|
||||
$errors = [];
|
||||
$trimmed = trim($pattern);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return ['الگوی شماره پرونده نمیتواند خالی باشد'];
|
||||
}
|
||||
if (mb_strlen($trimmed) > 60) {
|
||||
$errors[] = 'الگو نمیتواند بیش از ۶۰ نویسه باشد';
|
||||
}
|
||||
|
||||
// هر `{...}`ی که با توکنهای مجاز جور نباشد، یعنی توکن ناشناخته.
|
||||
$unknown = preg_replace(self::TOKEN_RE, '', $trimmed);
|
||||
if (preg_match('/\{[^}]*\}?/', (string) $unknown, $m) === 1) {
|
||||
$errors[] = sprintf('توکن ناشناخته: %s — مجاز: {YYYY}، {YY}، {MM}، {SEQ} یا {SEQ:n}', $m[0]);
|
||||
}
|
||||
|
||||
if (!str_contains($trimmed, '{SEQ')) {
|
||||
$errors[] = 'الگو باید {SEQ} یا {SEQ:n} داشته باشد، وگرنه همهٔ پروندهها یک شماره میگیرند';
|
||||
}
|
||||
|
||||
// {MM} بدون ریست ماهانه: پیشوندِ ماه عوض میشود ولی شمارنده ادامه پیدا میکند —
|
||||
// نتیجه ظاهراً منظم است و در واقع ترتیبِ ماه را نمیرساند.
|
||||
if (str_contains($trimmed, '{MM}') && $resetPolicy !== RecordNumberPattern::RESET_MONTHLY) {
|
||||
$errors[] = 'وقتی {MM} در الگو هست، ریست شمارنده باید ماهانه باشد';
|
||||
}
|
||||
if (str_contains($trimmed, '{YY}') || str_contains($trimmed, '{YYYY}')) {
|
||||
if ($resetPolicy === RecordNumberPattern::RESET_NONE) {
|
||||
$errors[] = 'وقتی سال در الگو هست، ریست شمارنده باید سالانه یا ماهانه باشد';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/** مهرِ دورهٔ فعلی: `1405` سالانه، `1405-05` ماهانه، `null` بدون ریست. */
|
||||
private function periodKey(string $resetPolicy): ?string
|
||||
{
|
||||
[$jy, $jm] = $this->today();
|
||||
|
||||
return match ($resetPolicy) {
|
||||
RecordNumberPattern::RESET_YEARLY => (string) $jy,
|
||||
RecordNumberPattern::RESET_MONTHLY => sprintf('%04d-%02d', $jy, $jm),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int} [سال، ماه] شمسیِ امروز به وقت تهران */
|
||||
private function today(): array
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone(JalaliDateService::TIMEZONE));
|
||||
[$jy, $jm] = $this->jalali->gregorianToJalali(
|
||||
(int) $now->format('Y'),
|
||||
(int) $now->format('m'),
|
||||
(int) $now->format('d'),
|
||||
);
|
||||
|
||||
return [$jy, $jm];
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,24 @@ class EntityContextResolver
|
||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||
}
|
||||
|
||||
/**
|
||||
* آیا این کاربر **صاحبِ** همین محیط است؟ (ادمین همیشه بله.)
|
||||
*
|
||||
* «صاحب» با «میتواند در آن بایستد» فرق دارد: پزشکِ مهمانِ یک کلینیک در محیط آن
|
||||
* میایستد ولی صاحبش نیست. تصمیمهایی که قراردادِ کلِ مجموعه را عوض میکنند —
|
||||
* مثل الگوی شمارهٔ پرونده — به این پرسش وصلاند، نه به مجوزهای per-resource.
|
||||
*/
|
||||
public function owns(User $user, string $entityType, ?int $entityId): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$ownedType, $ownedId] = $this->ownedEntity($user)->toEntityPair();
|
||||
|
||||
return $ownedId !== null && $ownedType === $entityType && $ownedId === $entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا
|
||||
* پرسنلِ فعالِ همان کلینیک.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* پروندههای ساختهشده پیش از فعالشدن الگو، اولین بار که باز میشوند شماره میگیرند
|
||||
* (`GET /api/v1/patient/{uuid}`) و از آن به بعد همان شماره را نگه میدارند.
|
||||
*/
|
||||
class RecordNumberBackfillTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Clinic, 2: PatientRecord} */
|
||||
private function scenario(bool $patternEnabled): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
$row = new RecordNumberPattern('clinic', $clinic->getId());
|
||||
$row->setPattern('OLD-{SEQ:3}')->setResetPolicy(RecordNumberPattern::RESET_NONE)->setEnabled($patternEnabled);
|
||||
$this->em->persist($row);
|
||||
|
||||
// پروندهٔ قدیمی: بدون شماره، دقیقاً مثل ردیفهای امروزِ دیتابیس.
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$patient->setRealName('بیمار قدیمی');
|
||||
$record = new PatientRecord('clinic', $clinic->getId(), $patient, 'clinic', $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertNull($record->getRecordNumber());
|
||||
|
||||
return [$owner, $clinic, $record];
|
||||
}
|
||||
|
||||
/** ✅ موفق + ⚠️ مرزی: بار اول شماره میگیرد، بار دوم همان شماره برمیگردد. */
|
||||
public function testFirstOpenAssignsANumberAndTheSecondKeepsIt(): void
|
||||
{
|
||||
[$owner, , $record] = $this->scenario(true);
|
||||
$uri = '/api/v1/patient/' . $record->getUuid();
|
||||
|
||||
$first = $this->authJson('GET', $uri, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('OLD-001', $first['data']['record_number']);
|
||||
|
||||
$second = $this->authJson('GET', $uri, $owner);
|
||||
self::assertSame('OLD-001', $second['data']['record_number']);
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: بدون الگوی فعال، `GET` هیچ چیزی نمینویسد. */
|
||||
public function testDisabledPatternLeavesTheRecordUntouched(): void
|
||||
{
|
||||
[$owner, , $record] = $this->scenario(false);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNull($res['data']['record_number']);
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: پروندهٔ شمارهدار دوباره شماره نمیگیرد و شمارنده مصرف نمیشود. */
|
||||
public function testAlreadyNumberedRecordDoesNotConsumeTheCounter(): void
|
||||
{
|
||||
[$owner, $clinic, $record] = $this->scenario(true);
|
||||
$record->setRecordNumber('MANUAL-7');
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid(), $owner);
|
||||
self::assertSame('MANUAL-7', $res['data']['record_number']);
|
||||
|
||||
$pattern = $this->em->getRepository(RecordNumberPattern::class)->findForPair('clinic', $clinic->getId());
|
||||
$this->em->refresh($pattern);
|
||||
self::assertSame(0, $pattern->getCounter());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Patient\Service\RecordNumberGenerator;
|
||||
use App\Representation\Service\JalaliDateService;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تولید شمارهٔ پرونده: رندر توکنها، اعتبارسنجی الگو، و شمارندهٔ تراکنشی.
|
||||
*/
|
||||
class RecordNumberGeneratorTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* سرویسهای دامنه در کانتینرِ تست public نیستند؛ ساختن مستقیم با همان
|
||||
* وابستگیهای تزریقی، هم قرارداد را حفظ میکند هم تست را از کانتینر جدا میکند.
|
||||
*/
|
||||
private function generator(): RecordNumberGenerator
|
||||
{
|
||||
return new RecordNumberGenerator($this->patternRepo(), $this->em, new JalaliDateService());
|
||||
}
|
||||
|
||||
private function patternRepo(): \App\Patient\Repository\RecordNumberPatternRepository
|
||||
{
|
||||
return $this->em->getRepository(RecordNumberPattern::class);
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int} [سال، ماه] شمسی امروز — تست به تاریخ اجرا وابسته نماند */
|
||||
private function todayJalali(): array
|
||||
{
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone(JalaliDateService::TIMEZONE));
|
||||
|
||||
[$jy, $jm] = (new JalaliDateService())->gregorianToJalali(
|
||||
(int) $now->format('Y'),
|
||||
(int) $now->format('m'),
|
||||
(int) $now->format('d'),
|
||||
);
|
||||
|
||||
return [$jy, $jm];
|
||||
}
|
||||
|
||||
private function pattern(string $pattern, string $reset, bool $enabled = true): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$row = new RecordNumberPattern('doctor', $doctor->getId());
|
||||
$row->setPattern($pattern)->setResetPolicy($reset)->setEnabled($enabled);
|
||||
$this->em->persist($row);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
public function testRendersEveryToken(): void
|
||||
{
|
||||
[$jy, $jm] = $this->todayJalali();
|
||||
$g = $this->generator();
|
||||
|
||||
self::assertSame((string) $jy, $g->render('{YYYY}', 1));
|
||||
self::assertSame(substr((string) $jy, -2), $g->render('{YY}', 1));
|
||||
self::assertSame(str_pad((string) $jm, 2, '0', STR_PAD_LEFT), $g->render('{MM}', 1));
|
||||
self::assertSame('7', $g->render('{SEQ}', 7));
|
||||
self::assertSame('0007', $g->render('{SEQ:4}', 7));
|
||||
self::assertSame('MD-' . substr((string) $jy, -2) . '-0012', $g->render('MD-{YY}-{SEQ:4}', 12));
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: شمارندهٔ بلندتر از پدینگ نباید بریده شود. */
|
||||
public function testCounterOverflowKeepsAllDigits(): void
|
||||
{
|
||||
self::assertSame('1000', $this->generator()->render('{SEQ:3}', 1000));
|
||||
}
|
||||
|
||||
public function testPreviewDoesNotConsumeTheCounter(): void
|
||||
{
|
||||
$doctor = $this->pattern('P-{SEQ:3}', RecordNumberPattern::RESET_NONE);
|
||||
$g = $this->generator();
|
||||
|
||||
self::assertSame('P-001', $g->preview('P-{SEQ:3}', 0));
|
||||
// شمارنده هنوز صفر است، پس اولین شمارهٔ واقعی همان ۱ میماند.
|
||||
self::assertSame('P-001', $g->next('doctor', $doctor->getId()));
|
||||
}
|
||||
|
||||
/** ✅ موفق: شمارهها پشت سر هم و بدون تکرار میآیند. */
|
||||
public function testNextIncrementsSequentially(): void
|
||||
{
|
||||
$doctor = $this->pattern('P-{SEQ:3}', RecordNumberPattern::RESET_NONE);
|
||||
$g = $this->generator();
|
||||
|
||||
self::assertSame('P-001', $g->next('doctor', $doctor->getId()));
|
||||
self::assertSame('P-002', $g->next('doctor', $doctor->getId()));
|
||||
self::assertSame('P-003', $g->next('doctor', $doctor->getId()));
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: بدون الگو یا با الگوی خاموش، رفتار امروز حفظ میشود. */
|
||||
public function testReturnsNullWhenPatternMissingOrDisabled(): void
|
||||
{
|
||||
$g = $this->generator();
|
||||
|
||||
$noPattern = new Doctor($this->createUser(['ROLE_DOCTOR']), 'بدون الگو');
|
||||
$this->em->persist($noPattern);
|
||||
$this->em->flush();
|
||||
self::assertNull($g->next('doctor', $noPattern->getId()));
|
||||
|
||||
$disabled = $this->pattern('P-{SEQ}', RecordNumberPattern::RESET_NONE, false);
|
||||
self::assertNull($g->next('doctor', $disabled->getId()));
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: ورود به دورهٔ جدید شمارنده را صفر میکند. */
|
||||
public function testCounterResetsWhenPeriodChanges(): void
|
||||
{
|
||||
$doctor = $this->pattern('{YY}-{SEQ:3}', RecordNumberPattern::RESET_YEARLY);
|
||||
$g = $this->generator();
|
||||
[$jy] = $this->todayJalali();
|
||||
$yy = substr((string) $jy, -2);
|
||||
|
||||
self::assertSame("$yy-001", $g->next('doctor', $doctor->getId()));
|
||||
self::assertSame("$yy-002", $g->next('doctor', $doctor->getId()));
|
||||
|
||||
// شبیهسازی سال قبل: مهرِ دوره را عقب میبریم، شمارنده باید صفر شود.
|
||||
$row = $this->patternRepo()->findForPair('doctor', $doctor->getId());
|
||||
$row->resetCounter((string) ($jy - 1));
|
||||
$row->incrementCounter();
|
||||
$row->incrementCounter();
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame("$yy-001", $g->next('doctor', $doctor->getId()));
|
||||
}
|
||||
|
||||
/** ❌ خطا: الگوهای نامعتبر. */
|
||||
public function testValidatePatternRejectsBadInput(): void
|
||||
{
|
||||
$g = $this->generator();
|
||||
|
||||
self::assertNotEmpty($g->validatePattern('', RecordNumberPattern::RESET_NONE));
|
||||
self::assertNotEmpty($g->validatePattern('MD-{FOO}-{SEQ}', RecordNumberPattern::RESET_NONE));
|
||||
self::assertNotEmpty($g->validatePattern('MD-0001', RecordNumberPattern::RESET_NONE));
|
||||
self::assertNotEmpty($g->validatePattern(str_repeat('x', 61) . '{SEQ}', RecordNumberPattern::RESET_NONE));
|
||||
// {MM} بدون ریست ماهانه و {YY} بدون ریست، ترتیب را بیمعنا میکنند.
|
||||
self::assertNotEmpty($g->validatePattern('{MM}-{SEQ}', RecordNumberPattern::RESET_YEARLY));
|
||||
self::assertNotEmpty($g->validatePattern('{YY}-{SEQ}', RecordNumberPattern::RESET_NONE));
|
||||
|
||||
self::assertSame([], $g->validatePattern('MD-{YY}-{SEQ:4}', RecordNumberPattern::RESET_YEARLY));
|
||||
self::assertSame([], $g->validatePattern('P-{SEQ:5}', RecordNumberPattern::RESET_NONE));
|
||||
self::assertSame([], $g->validatePattern('{YY}{MM}-{SEQ:3}', RecordNumberPattern::RESET_MONTHLY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\RecordNumberPattern;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* شمارهٔ پرونده هنگام ساختِ پرونده از الگو ساخته میشود — و ورودِ دستی فقط از
|
||||
* صاحب مجموعه پذیرفته میشود.
|
||||
*/
|
||||
class RecordNumberOnCreateTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Clinic, 2: Doctor} */
|
||||
private function clinicWithPattern(?string $pattern = 'MD-{SEQ:4}', bool $enabled = true): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر عضو');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
if ($pattern !== null) {
|
||||
$row = new RecordNumberPattern('clinic', $clinic->getId());
|
||||
$row->setPattern($pattern)->setResetPolicy(RecordNumberPattern::RESET_NONE)->setEnabled($enabled);
|
||||
$this->em->persist($row);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// فیچرِ `patient_records` از پلنِ «free» میآید که ApiTestCase تضمینش میکند.
|
||||
return [$owner, $clinic, $doctor];
|
||||
}
|
||||
|
||||
private function createPatientPayload(string $suffix): array
|
||||
{
|
||||
return [
|
||||
'mobile' => '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT),
|
||||
'name' => 'بیمار ' . $suffix,
|
||||
'national_code' => (string) random_int(1_000_000_000, 9_999_999_999),
|
||||
];
|
||||
}
|
||||
|
||||
/** ✅ موفق: بدون فرستادن شماره، سرور از الگو میسازد و دنباله جلو میرود. */
|
||||
public function testCreateGeneratesSequentialNumbers(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithPattern();
|
||||
|
||||
$first = $this->authJson('POST', '/api/v1/patient', $owner, $this->createPatientPayload('الف'));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('MD-0001', $first['data']['record_number']);
|
||||
|
||||
$second = $this->authJson('POST', '/api/v1/patient', $owner, $this->createPatientPayload('ب'));
|
||||
self::assertSame('MD-0002', $second['data']['record_number']);
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: بدون الگوی فعال، رفتار امروز حفظ میشود (شمارهٔ دستی، برای همه). */
|
||||
public function testWithoutPatternTheManualNumberStillWins(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithPattern(null);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient', $owner, [
|
||||
...$this->createPatientPayload('ج'),
|
||||
'record_number' => 'کاغذی-77',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('کاغذی-77', $res['data']['record_number']);
|
||||
}
|
||||
|
||||
/** ✅ موفق: صاحب مجموعه میتواند خارج از دنباله شماره بگذارد. */
|
||||
public function testOwnerMayOverrideTheGeneratedNumber(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithPattern();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient', $owner, [
|
||||
...$this->createPatientPayload('د'),
|
||||
'record_number' => 'ARCHIVE-1',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('ARCHIVE-1', $res['data']['record_number']);
|
||||
}
|
||||
|
||||
/** ❌ خطا: منشی حق ورود دستی ندارد — نه در ساخت، نه در ویرایش. */
|
||||
public function testSecretaryCannotSetTheNumberManually(): void
|
||||
{
|
||||
[, $clinic, $doctor] = $this->clinicWithPattern();
|
||||
|
||||
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
||||
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
|
||||
$rel->mergePermissions(['resources' => ['patients' => ['view' => true, 'create' => true, 'update' => true]]]);
|
||||
$this->em->persist($rel);
|
||||
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/patient', $secretary, [
|
||||
...$this->createPatientPayload('ه'),
|
||||
'record_number' => 'HAND-9',
|
||||
]);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
|
||||
// بدون شمارهٔ دستی همان منشی میتواند پرونده بسازد و شماره از الگو میآید.
|
||||
$ok = $this->authJson('POST', '/api/v1/patient', $secretary, $this->createPatientPayload('و'));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('MD-0001', $ok['data']['record_number']);
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: پروندهٔ خودکارِ نوبت هم در همان دنباله مینشیند. */
|
||||
public function testAutoCreatedRecordFromAppointmentGetsANumber(): void
|
||||
{
|
||||
[, $clinic, $doctor] = $this->clinicWithPattern();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$patient->setRealName('بیمار نوبت');
|
||||
$this->em->flush();
|
||||
|
||||
$appointment = new \App\Appointment\Entity\Appointment($doctor, $patient, time() + 3_600, time() + 5_400);
|
||||
$appointment->setClinic($clinic);
|
||||
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $clinic));
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$service = static::getContainer()->get(\App\Patient\Service\PatientService::class);
|
||||
$session = $service->autoCreateOnAppointmentConfirm($appointment);
|
||||
|
||||
self::assertNotNull($session);
|
||||
self::assertSame('MD-0001', $session->getRecord()->getRecordNumber());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET/PUT /api/v1/patient-record-number-settings — الگوی شمارهٔ پروندهٔ محیط جاری.
|
||||
* خواندن برای هر کسی که بیماران را میبیند؛ نوشتن فقط برای صاحب محیط.
|
||||
*/
|
||||
class RecordNumberSettingsApiTest extends ApiTestCase
|
||||
{
|
||||
private const URI = '/api/v1/patient-record-number-settings';
|
||||
|
||||
/** @return array{0: User, 1: Clinic, 2: Doctor} مالک کلینیک + کلینیک + پزشکِ عضو */
|
||||
private function clinic(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر عضو');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic, $doctor];
|
||||
}
|
||||
|
||||
public function testOwnerReadsDefaultsBeforeAnythingIsSaved(): void
|
||||
{
|
||||
[$owner] = $this->clinic();
|
||||
|
||||
$res = $this->authJson('GET', self::URI, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($res['data']['enabled']);
|
||||
self::assertSame('none', $res['data']['reset_policy']);
|
||||
self::assertSame(0, $res['data']['counter']);
|
||||
self::assertTrue($res['data']['can_edit']);
|
||||
}
|
||||
|
||||
/** ✅ موفق: ذخیرهٔ الگو و پیشنمایش شمارهٔ بعدی. */
|
||||
public function testOwnerSavesPatternAndGetsPreview(): void
|
||||
{
|
||||
[$owner] = $this->clinic();
|
||||
|
||||
$res = $this->authJson('PUT', self::URI, $owner, [
|
||||
'enabled' => true,
|
||||
'pattern' => 'MD-{SEQ:4}',
|
||||
'reset_policy' => 'none',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['enabled']);
|
||||
self::assertSame('MD-{SEQ:4}', $res['data']['pattern']);
|
||||
self::assertSame('MD-0001', $res['data']['next_preview']);
|
||||
|
||||
// GET بعدی همان مقدارِ ذخیرهشده را میدهد (نه پیشفرض).
|
||||
$read = $this->authJson('GET', self::URI, $owner);
|
||||
self::assertSame('MD-{SEQ:4}', $read['data']['pattern']);
|
||||
}
|
||||
|
||||
/** ❌ خطا: منشی نه مینویسد، نه `can_edit` میگیرد. */
|
||||
public function testSecretaryCannotWriteButCanRead(): void
|
||||
{
|
||||
[, $clinic, $doctor] = $this->clinic();
|
||||
|
||||
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
||||
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic));
|
||||
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
$read = $this->authJson('GET', self::URI, $secretary);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($read['data']['can_edit']);
|
||||
|
||||
$this->authJson('PUT', self::URI, $secretary, [
|
||||
'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'none',
|
||||
]);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
/** ❌ خطا: پزشکِ مهمانِ کلینیک هم صاحب آن محیط نیست. */
|
||||
public function testGuestDoctorCannotChangeTheClinicPattern(): void
|
||||
{
|
||||
[, $clinic, $doctor] = $this->clinic();
|
||||
$this->em->persist(new UserActiveContext($doctor->getUser(), $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PUT', self::URI, $doctor->getUser(), [
|
||||
'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'none',
|
||||
]);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
/** ❌ خطا: الگوهای نامعتبر همگی ۴۲۲ با فیلد درست. */
|
||||
public function testInvalidPatternsAreRejected(): void
|
||||
{
|
||||
[$owner] = $this->clinic();
|
||||
|
||||
foreach ([
|
||||
['MD-{FOO}-{SEQ}', 'none'], // توکن ناشناخته
|
||||
['MD-0001', 'none'], // بدون {SEQ}
|
||||
['', 'none'], // خالی
|
||||
['{YY}-{SEQ}', 'none'], // سال بدون ریست
|
||||
['{MM}-{SEQ}', 'yearly'], // ماه بدون ریست ماهانه
|
||||
] as [$pattern, $reset]) {
|
||||
$res = $this->authJson('PUT', self::URI, $owner, [
|
||||
'enabled' => true, 'pattern' => $pattern, 'reset_policy' => $reset,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode(), "pattern: $pattern");
|
||||
self::assertSame('pattern', $res['errors'][0]['field'] ?? null);
|
||||
}
|
||||
|
||||
// سیاست ریستِ ناشناخته هم رد میشود، ولی فیلدش reset_policy است.
|
||||
$res = $this->authJson('PUT', self::URI, $owner, [
|
||||
'enabled' => true, 'pattern' => 'X-{SEQ}', 'reset_policy' => 'weekly',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('reset_policy', $res['errors'][0]['field'] ?? null);
|
||||
}
|
||||
|
||||
/** ⚠️ مرزی: تغییر الگو شمارنده را صفر نمیکند — شمارههای صادرشده وجود دارند. */
|
||||
public function testChangingThePatternKeepsTheCounter(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->clinic();
|
||||
|
||||
$this->authJson('PUT', self::URI, $owner, [
|
||||
'enabled' => true, 'pattern' => 'A-{SEQ:3}', 'reset_policy' => 'none',
|
||||
]);
|
||||
|
||||
$repo = $this->em->getRepository(\App\Patient\Entity\RecordNumberPattern::class);
|
||||
$row = $repo->findForPair('clinic', $clinic->getId());
|
||||
$row->incrementCounter();
|
||||
$row->incrementCounter();
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('PUT', self::URI, $owner, [
|
||||
'enabled' => true, 'pattern' => 'B-{SEQ:3}', 'reset_policy' => 'none',
|
||||
]);
|
||||
|
||||
self::assertSame(2, $res['data']['counter']);
|
||||
self::assertSame('B-003', $res['data']['next_preview']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user