# نوبت‌دهی ادمین بر اساس کد ملی + موبایل (پرونده یکتا با کد ملی) ## پروژه `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 طبق قاعده‌ی پروژه).