Files
clinicpro/.claude/prompt/national-code-unique-profile.md
T

150 lines
9.4 KiB
Markdown

# یکتا بودن کد ملی در پروفایل بیمار (هر کد ملی فقط یک پروفایل)
## پروژه
`clinicpro` (Backend — منبع واحد داده و constraint).
> این تغییر cross-repo اثر دارد: سایت عمومی `nobat724_front` و پنل ادمین React (داخل همین پروژه) فقط باید خطای `409`/`422` جدید را نمایش دهند. قرارداد پاسخ خطا در «نکات مهم» آمده تا کلاینت‌ها مصرف کنند. تغییر منطق فقط backend است.
## زمینه
هر کاربر (`User`) دقیقاً یک پروفایل (`UserProfile`, جدول `profiles`, رابطه `OneToOne` با کلید یکتای `idx_profiles_user`) دارد. کد ملی هم روی `User` و هم روی `UserProfile` ذخیره می‌شود ولی **در هیچ‌کدام یکتا نیست** — فقط یک ایندکس غیر-یکتا روی `profiles.national_code` هست:
```php
// src/UserProfile/Entity/UserProfile.php:12
#[ORM\Index(columns: ['national_code'], name: 'idx_profiles_national_code')]
// src/UserProfile/Entity/UserProfile.php:36
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
// src/Auth/Entity/User.php:35
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
```
پرامپت قبلی `fix-server-side-mobile-nationalcode-validation.md` فقط **فرمت** کد ملی (رقم کنترلی ایرانی) را سمت سرور اضافه کرد، نه **یکتایی** را. در نتیجه دو پروفایل مختلف می‌توانند یک کد ملی داشته باشند — چه از مسیر پروفایل عمومی، چه نوبت‌دهی توسط منشی.
## مشکل / هدف
کد ملی باید در سطح بیمار یکتا باشد: نباید دو پروفایل جدا با یک کد ملی وجود داشته باشد. وقتی کاربر/منشی/هر مسیرِ نوبت‌دهی کد ملی‌ای ثبت می‌کند که قبلاً به پروفایل **کاربر دیگری** تعلق دارد، باید با خطای `409` رد شود (نه ذخیره).
دامنه‌ی یکتایی: **`UserProfile.national_code`** (منبع نهاییِ هویت بیمار، رابطه‌ی ۱:۱ با کاربر). `User.nationalCode` در حد همگام‌سازی است و قید یکتایی روی پروفایل کافی است.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/UserProfile/Entity/UserProfile.php` | افزودن قید یکتای partial روی `national_code` |
| `src/UserProfile/Repository/UserProfileRepository.php` | متد `findOneByNationalCode()` جدید |
| `src/UserProfile/Controller/UserProfileController.php` | `hydrate()` / `update()` — چک یکتایی قبل از set |
| `src/Shared/Constant/ErrorCodes.php` | کد خطای جدید برای کد ملی تکراری |
| `migrations/VersionXXXX.php` (جدید) | unique index روی `profiles.national_code` |
| `docs/api/user-profile.md` | مستندسازی پاسخ `409` |
## وضعیت فعلی (کد واقعی)
`UserProfileController::hydrate` — بدون چک یکتایی ذخیره می‌شود:
```php
// src/UserProfile/Controller/UserProfileController.php:211
if (array_key_exists('national_code', $data)) $profile->setNationalCode($data['national_code']);
```
`update()` فقط فرمت را چک می‌کند (از پرامپت قبلی)، یکتایی نه:
```php
// src/UserProfile/Controller/UserProfileController.php:134
if (array_key_exists('national_code', $data) && $data['national_code'] !== null && $data['national_code'] !== '') {
$code = InputValidator::toEnglishDigits((string) $data['national_code']);
if (!InputValidator::isValidIranNationalCode($code)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code');
}
$data['national_code'] = $code;
}
```
## وظایف
### ۱. قید یکتای دیتابیس (partial — فقط مقادیر non-null)
در `UserProfile.php` ایندکس فعلی را به unique تبدیل کن. چون `national_code` nullable است و چند پروفایلِ بدون کد ملی باید مجاز بمانند، در MariaDB رفتار پیش‌فرضِ unique index این است که چندین `NULL` مجاز است (دقیقاً همان partial-unique موردنظر) — پس کافی است index را unique کنیم:
```php
// به‌جای خط 12
#[ORM\UniqueConstraint(name: 'uniq_profiles_national_code', columns: ['national_code'])]
```
سپس migration بساز:
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
```
**هشدار داده‌ی موجود:** قبل از migrate، تکراری‌های فعلی را پیدا کن وگرنه migration روی `ALTER TABLE` می‌شکند:
```sql
SELECT national_code, COUNT(*) c FROM profiles
WHERE national_code IS NOT NULL AND national_code <> ''
GROUP BY national_code HAVING c > 1;
```
اگر تکراری بود، در توضیحات خروجی به کاربر گزارش بده (پاک‌سازی دستی لازم است؛ خودسرانه merge/حذف نکن).
### ۲. متد repository
در `UserProfileRepository.php`:
```php
public function findOneByNationalCode(string $nationalCode): ?UserProfile
{
return $this->findOneBy(['nationalCode' => $nationalCode]);
}
```
### ۳. چک یکتایی در سطح اپلیکیشن (قبل از flush)
قید DB لایه‌ی آخر است؛ برای پیام تمیز و جلوگیری از `UniqueConstraintViolationException`، در `UserProfileController::update()` بعد از validation فرمت و قبل از `hydrate`، یکتایی را چک کن. اگر کد ملی به پروفایلی با `uuid` **متفاوت** تعلق دارد → `409`:
```php
// داخل update()، بعد از بلوک normalize/validate فرمت (خط ~140)
if (isset($data['national_code']) && $data['national_code'] !== '') {
$existing = $this->repository->findOneByNationalCode($data['national_code']);
if ($existing !== null && $existing->getUuid() !== $profile->getUuid()) {
return $this->error(
ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN,
'این کد ملی قبلاً برای کاربر دیگری ثبت شده است',
409,
'national_code'
);
}
}
```
### ۴. کد خطا
در `src/Shared/Constant/ErrorCodes.php` کنار سایر `ERR_VALIDATION_*` اضافه کن:
```php
public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001';
```
و پیام فارسی متناظرش را در همان نگاشتِ پیام‌ها (همان‌جا که بقیه کدها پیام دارند) قرار بده: «این کد ملی قبلاً برای کاربر دیگری ثبت شده است».
### ۵. مسیرهای نوبت‌دهی منشی / سایر
هر نقطه‌ای که از مسیرِ غیر-پروفایل کد ملی روی پروفایل یا کاربر می‌نشاند، باید همین چک `findOneByNationalCode` را قبل از set اجرا کند. بررسی کن این نقاط را و در صورت وجود set کد ملی روی `UserProfile`/`User`، چک را اضافه کن:
- `src/Patient/Controller/PatientController.php` (`createPatient` — خطوط ~133–160، set روی `User`)
- `src/Staff/Controller/StaffController.php` (خطوط 64, 91 — `ClinicStaff.nationalCode`؛ این **پرسنل** است نه بیمار، فقط در صورتی که staff به پروفایل بیمار map می‌شود چک لازم است — اگر نه، دست نزن)
برای مسیر منشی/پنل که از `UserProfileController::update` می‌گذرد، وظیفه‌ی ۳ کافی است و نیازی به تکرار نیست.
## نکات مهم
- **partial-unique با NULL:** عمداً `NULL` را آزاد می‌گذاریم تا پروفایل‌های ناقصِ بدون کد ملی (که سیستم به‌صورت lazy می‌سازد — `resolveProfile`، خط 193) نشکنند. هرگز رشته‌ی خالی `''` ذخیره نکن؛ همیشه `null`. اگر جایی `''` می‌نشیند، به `null` نرمالایز کن وگرنه دو پروفایلِ `''` قید را می‌شکنند.
- **دامنه‌ی یکتایی = global** (نه per-clinic). یک کد ملی در کل سیستم = یک بیمار.
- چک اپلیکیشنی **race-condition** دارد (دو request همزمان)؛ قید DB لایه‌ی نهایی است. اگر خواستی، `UniqueConstraintViolationException` را در یک try/catch دور `save()` بگیر و همان `409` را برگردان تا حالت رقابتی هم پیام تمیز بدهد.
- همه‌ی پاسخ‌ها از `BaseController`؛ خطا با `$this->error($code, $msg, 409, 'national_code')`.
- بعد از تغییر `UserProfileController`، طبق قانون استاندارد پروژه `docs/api/user-profile.md` را در همین session به‌روز کن (افزودن پاسخ `409`).
- **مصرف‌کنندگان (cross-repo):** سایت عمومی و پنل ادمین باید کد `ERR_PROFILE_001` / status `409` را روی فیلد `national_code` نمایش دهند. این در همین پرامپت پیاده نمی‌شود؛ فقط قرارداد را در doc ثبت کن تا کلاینت‌ها وصل شوند.