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>
12 KiB
نوبتدهی ادمین بر اساس کد ملی + موبایل (پرونده یکتا با کد ملی)
پروژه
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). پس هویت درستِ بیمار = کد ملی، و موبایل صرفاً یک راه تماس است.
هدف
در ثبت نوبتِ ادمین، بیمار باید با کد ملی + موبایل شناسایی شود:
- کد ملی در فرم و در هر دو endpoint ادمین الزامی و معتبر شود.
- رکورد
Userبیمار اول با کد ملی resolve شود (نه صرفاً موبایل)، تا پرونده برای یک کد ملی یکتا بماند حتی اگر موبایل عوض شود. 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, خط ۲۴۲–۲۶۳)
$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, خط ۸۱–۸۷)
$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)
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 اضافه کن:
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 طبق قاعدهی پروژه).