Admin-side booking (POST /api/v1/my/appointment and /api/v1/admin/appointment) resolved the patient User by mobile only, so one person booked under two mobiles produced two User rows — and two case-files, since PatientRecord is keyed on user_id. National code is the real unique identity (User.national_code is already unique); a person may have several mobiles. Booking now requires + validates patient_national_code and resolves the patient national-code-first (then mobile) via a shared PatientResolver, so the case-file stays unique per national code even across mobiles. Reusing a mobile already bound to a different national code returns 422 ERR_PROFILE_MOBILE_TAKEN. The admin create form and NewAppointmentDrawer gain a national-code field and send it; both had a dead patient-picker URL (/api/v1/patient) fixed to the real /api/v1/patients, whose payload already carries user_national_code for autofill. Docs (appointment.md, admin.md) and tests updated; new AppointmentNationalCodeTest covers success, single-file reuse, missing, invalid, and identity-conflict cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
12 KiB
Markdown
155 lines
12 KiB
Markdown
# نوبتدهی ادمین بر اساس کد ملی + موبایل (پرونده یکتا با کد ملی)
|
||
|
||
## پروژه
|
||
|
||
`clinicpro` (backend Symfony + پنل ادمین React). تکریپو — نیازی به تغییر `nobat724_front` نیست.
|
||
|
||
> توجه: endpoint عمومی سایت (`POST /api/v1/appointment` → `AppointmentController::book`) **از قبل** کد ملی را الزامی و اعتبارسنجی میکند. این تسک فقط شکافِ مسیر **ادمین/کلینیک/منشی/پزشک** را میبندد که هنوز بیمار را فقط با موبایل resolve میکند.
|
||
|
||
## زمینه
|
||
|
||
پروندهی بیمار (`PatientRecord`) روی `user_id` کلید میخورد (UniqueConstraint: `entity_type + entity_id + user_id`) و در `PatientService::autoCreateForEntity` از `$appointment->getUser()` ساخته میشود. یعنی هویت پرونده = رکورد `User`. اما رکورد `User` در مسیر ثبت نوبتِ ادمین فقط با **موبایل** پیدا/ساخته میشود:
|
||
|
||
- `src/Appointment/Controller/MyAppointmentsController.php` خط ۸۱: `findOneBy(['mobileNumber' => $mobile])`
|
||
- `src/Admin/Controller/AdminApiController.php` خط ۸۷۰: `findOneBy(['mobileNumber' => $mobile])`
|
||
|
||
نتیجه: یک شخص با دو موبایل مختلف → دو `User` مجزا → دو پروندهی مجزا. در حالی که کد ملی یکتاست (`User.national_code` هماکنون `unique: true, nullable: true`). پس هویت درستِ بیمار = **کد ملی**، و موبایل صرفاً یک راه تماس است.
|
||
|
||
## هدف
|
||
|
||
در ثبت نوبتِ ادمین، بیمار باید با **کد ملی + موبایل** شناسایی شود:
|
||
|
||
1. کد ملی در فرم و در هر دو endpoint ادمین **الزامی و معتبر** شود.
|
||
2. رکورد `User` بیمار **اول با کد ملی** resolve شود (نه صرفاً موبایل)، تا پرونده برای یک کد ملی یکتا بماند حتی اگر موبایل عوض شود.
|
||
3. `patient_national_code` روی `Appointment` ذخیره شود (فیلد و setter از قبل موجود است: `Appointment::setPatientNationalCode`).
|
||
|
||
## فایلهای مرتبط
|
||
|
||
| فایل | نقش | تغییر |
|
||
|------|-----|-------|
|
||
| `src/Appointment/Controller/MyAppointmentsController.php` | endpoint `POST /api/v1/my/appointment` (doctor/clinic/secretary/admin) | الزام + resolve با کد ملی |
|
||
| `src/Admin/Controller/AdminApiController.php` | endpoint `POST /api/v1/admin/appointment` (فقط admin) | الزام + resolve با کد ملی |
|
||
| `src/Auth/Repository/UserRepository.php` | فقط `findByMobile` دارد | افزودن `findByNationalCode` |
|
||
| `src/Patient/Service/PatientService.php` | `resolvePatientUser` مشترک (اختیاری، ضدتکرار) | استخراج منطق resolve |
|
||
| `assets/admin/pages/AppointmentCreatePage.tsx` | فرم ثبت نوبت | افزودن فیلد کد ملی + ارسال در payload |
|
||
| `src/Shared/…/InputValidator.php` | `toEnglishDigits` + `isValidIranNationalCode` (استفادهشده در `book`) | فقط استفاده |
|
||
| `docs/api/appointment.md` + `docs/api/admin.md` | مستندات endpoint | بهروزرسانی |
|
||
|
||
## وضعیت فعلی (کد واقعی)
|
||
|
||
### `book()` عمومی — الگوی درستِ موجود (کپی از `AppointmentController::book`, خط ۲۴۲–۲۶۳)
|
||
|
||
```php
|
||
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
||
if ($nationalCode === '') {
|
||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی بیمار الزامی است', 422, 'patient_national_code');
|
||
}
|
||
if (!InputValidator::isValidIranNationalCode($nationalCode)) {
|
||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'patient_national_code');
|
||
}
|
||
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||
$appointment->setPatientNationalCode($nationalCode);
|
||
```
|
||
|
||
### مسیر ادمین — بیمار فقط با موبایل (کپی از `MyAppointmentsController::createAppointment`, خط ۸۱–۸۷)
|
||
|
||
```php
|
||
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||
if (!$patient) {
|
||
$patient = new User($mobile);
|
||
$patient->setRealName($patientName);
|
||
$patient->setRoles(['ROLE_USER']);
|
||
$this->em->persist($patient);
|
||
}
|
||
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||
```
|
||
(`AdminApiController::createAppointment` خط ۸۷۰–۸۷۶ دقیقاً همین است.)
|
||
|
||
### فرم — بدون فیلد کد ملی (کپی از `AppointmentCreatePage.tsx`)
|
||
|
||
```tsx
|
||
const [name, setName] = useState('');
|
||
const [mobile, setMobile] = useState('');
|
||
// ...
|
||
const effectiveName = picked?.user_name || name.trim();
|
||
const effectiveMobile = picked?.user_mobile || mobile.trim();
|
||
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && !!start && !!end;
|
||
// payload:
|
||
patient_name: effectiveName,
|
||
patient_mobile: effectiveMobile,
|
||
```
|
||
> نکته: ردیفهای جستجوی بیمار (`PatientRow`) فقط `user_name` و `user_mobile` دارند؛ برای پرکردن خودکارِ کد ملیِ بیمارِ انتخابشده باید `user_national_code` هم از endpoint جستجو (`GET /api/v1/patient`) بیاید — بررسی کن آیا برمیگردد؛ اگر نه، آن را هم به خروجی اضافه کن (این فیلد در `PatientRecord::toArray` خط ۱۱۶ موجود است).
|
||
|
||
## وظایف
|
||
|
||
### ۱. `UserRepository::findByNationalCode`
|
||
|
||
در `src/Auth/Repository/UserRepository.php` کنار `findByMobile` اضافه کن:
|
||
|
||
```php
|
||
public function findByNationalCode(string $nationalCode): ?User
|
||
{
|
||
return $this->findOneBy(['nationalCode' => $nationalCode]);
|
||
}
|
||
```
|
||
|
||
### ۲. منطق resolve بیمار با کد ملی (اولویت با کد ملی، سپس موبایل)
|
||
|
||
یک متد مشترک بساز تا در هر دو endpoint استفاده شود (DRY + SOLID). مکان پیشنهادی: `PatientService::resolvePatientUser` (یا یک سرویس کوچک اختصاصی اگر تزریق `PatientService` سنگین بود — تصمیم را در کد بنویس).
|
||
|
||
قاعدهی resolve:
|
||
|
||
```
|
||
nationalCode معتبر ورودی + mobile + name داریم:
|
||
1) user = userRepo.findByNationalCode(nationalCode)
|
||
2) اگر نبود: user = userRepo.findByMobile(mobile)
|
||
- اگر پیدا شد و nationalCode او خالی است → user.setNationalCode(nationalCode)
|
||
- اگر پیدا شد و nationalCode او با ورودی فرق دارد → خطای 422
|
||
«این شماره موبایل به کد ملی دیگری تعلق دارد» (تعارض هویت)
|
||
3) اگر هیچکدام نبود: user جدید با mobile، setRealName(name)، setNationalCode(nationalCode)، ROLE_USER، persist
|
||
4) اگر user با کد ملی پیدا شد ولی mobileنش با ورودی فرق دارد → موبایل را بهروز نکن
|
||
(کد ملی مرجع است؛ یک کد ملی میتواند چند موبایل داشته باشد — فقط پرونده یکتا بماند).
|
||
نامِ خالیِ user را با name پر کن.
|
||
```
|
||
|
||
> چرا اولویت با کد ملی: خواستهی صریح — «یک کاربر ممکن است با چند موبایل باشد و پرونده برای یک کد ملی یکتا». چون `PatientRecord` روی `user_id` است، تا وقتی برای یک کد ملی همان `User` برگردد، پرونده یکتا میماند.
|
||
|
||
### ۳. الزام + اعتبارسنجی کد ملی در دو endpoint ادمین
|
||
|
||
در **هر دو** `MyAppointmentsController::createAppointment` و `AdminApiController::createAppointment`:
|
||
|
||
- بعد از خواندن `$mobile`/`$patientName`، `patient_national_code` را با همان الگوی `book()` بخوان، `toEnglishDigits` کن، خالیبودن و `isValidIranNationalCode` را چک کن (خطای 422 با فیلد `patient_national_code`).
|
||
- `$patient` را با متد resolve وظیفهی ۲ بگیر (بهجای `findOneBy(['mobileNumber' => $mobile])`).
|
||
- `$appointment->setPatientNationalCode($nationalCode)` را ست کن (مثل `book`).
|
||
- `patient_gender` را **الزامی نکن** مگر اینکه قبلاً در این مسیر الزامی بوده باشد — `book` عمومی جنسیت را الزامی میکند ولی مسیر ادمین تاکنون نمیکرده؛ رفتار فعلی را حفظ کن و فقط کد ملی را اضافه کن (اسکوپ حداقلی).
|
||
- به `ErrorCodes` مسیر ادمین دقت کن: این کنترلرها از ثابتهای کوتاه (`ErrorCodes::VALIDATION`, `ErrorCodes::DOCTOR_NOT_FOUND` …) استفاده میکنند، نه `ERR_VALIDATION_001`. از همان سبکِ همان فایل استفاده کن.
|
||
|
||
### ۴. فرم `AppointmentCreatePage.tsx`
|
||
|
||
- state جدید: `const [nationalCode, setNationalCode] = useState('')`.
|
||
- در بلوک «مراجعه کننده جدید» (`picked === null`) یک فیلد ورودی کد ملی اضافه کن (کنار نام/موبایل). ورودی فارسی/انگلیسی را بپذیر ولی فقط رقم؛ maxLength=10، `dir="ltr"`.
|
||
- `effectiveNationalCode = picked?.user_national_code || nationalCode.trim()`.
|
||
- `valid` را گسترش بده: کد ملی باید ۱۰ رقم باشد (اعتبارسنجی کاملِ کد ملی سمت بکاند است؛ سمت فرانت فقط طول/رقم).
|
||
- در `payload`: `patient_national_code: effectiveNationalCode`.
|
||
- `PatientRow` را با `user_national_code?: string` گسترش بده و اگر endpoint جستجو آن را برنگرداند، در وظیفهی مرتبط بکاند اضافهاش کن تا انتخاب بیمارِ موجود، فیلد را پر کند.
|
||
|
||
### ۵. مستندات
|
||
|
||
`docs/api/appointment.md` (برای `/api/v1/my/appointment`) و `docs/api/admin.md` (برای `/api/v1/admin/appointment`) را بهروز کن: افزودهشدن فیلد الزامی `patient_national_code`، خطای 422 تعارض موبایل/کد ملی، و رفتار «resolve با کد ملی».
|
||
|
||
## نکات مهم
|
||
|
||
- **SOLID/DRY:** منطق resolve بیمار را یکجا بنویس؛ در دو کنترلر کپیپیست نکن. دلیلِ محلِ قرارگیری را در کامنت بنویس.
|
||
- **یکتایی DB:** `User.national_code` هماکنون `unique: true` است — نیازی به migration نیست مگر تغییری در entity بدهی. اگر تغییری ندادی، migration نساز.
|
||
- **تعارض هویت (edge مهم):** موبایلی که قبلاً با کد ملیِ X ثبت شده، حالا با کد ملیِ Y بیاید → باید خطای روشن بدهی، نه اینکه کد ملی را عوض کنی (چون verify قبلی را باطل و داده را خراب میکند؛ `User::setNationalCode` خط ۹۵ خودش `nationalCodeVerified=false` میکند).
|
||
- **`for_self` نداریم اینجا:** مسیر ادمین همیشه برای «دیگری» است؛ برخلاف `book`، `$user` جاری پزشک/منشی است نه بیمار. بیمار همیشه از موبایل/کد ملیِ ورودی resolve میشود.
|
||
- **ارقام فارسی:** همیشه `InputValidator::toEnglishDigits` روی کد ملی و موبایل قبل از جستجو/ذخیره (منشی معمولاً فارسی تایپ میکند).
|
||
- **تست (الزامی — موفق/خطا/مرزی):**
|
||
- موفق: بیمار جدید با کد ملی → `User` با `national_code` ساخته شد + نوبت ثبت شد.
|
||
- موفق (یکتایی پرونده): همان کد ملی با موبایلِ متفاوت در نوبت دوم → همان `User` برگردد (نه User جدید)؛ پس از confirm، `PatientRecord` یکتا بماند.
|
||
- خطا: کد ملی خالی → 422 `patient_national_code`.
|
||
- خطا: کد ملی نامعتبر (checksum) → 422.
|
||
- مرزی/تعارض: موبایلِ موجود با کد ملیِ متفاوت → 422 تعارض هویت.
|
||
- تستها را با `ddev exec php bin/phpunit` و type-check فرانت را با `npx tsc --noEmit` اجرا کن. بدون سبز شدن، تسک تمام نیست.
|
||
- بعد از تغییر کد: `graphify update .` (اول commit طبق قاعدهی پروژه).
|