diff --git a/.claude/prompt/appointment-book-national-code.md b/.claude/prompt/appointment-book-national-code.md new file mode 100644 index 00000000..f71124be --- /dev/null +++ b/.claude/prompt/appointment-book-national-code.md @@ -0,0 +1,154 @@ +# نوبت‌دهی ادمین بر اساس کد ملی + موبایل (پرونده یکتا با کد ملی) + +## پروژه + +`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 طبق قاعده‌ی پروژه). diff --git a/assets/admin/components/NewAppointmentDrawer.test.tsx b/assets/admin/components/NewAppointmentDrawer.test.tsx index abd14f52..b28fe4cc 100644 --- a/assets/admin/components/NewAppointmentDrawer.test.tsx +++ b/assets/admin/components/NewAppointmentDrawer.test.tsx @@ -19,7 +19,7 @@ beforeEach(() => { if (url === '/api/v1/service-sections') return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }] }); if (url.startsWith('/api/v1/service-items/sec1')) return Promise.resolve({ success: true, data: [{ uuid: 'it1', name: 'لیزر توتال' }] }); if (url === '/api/v1/staff') return Promise.resolve({ success: true, data: [{ uuid: 'st1', full_name: 'سحر ایمانی' }] }); - if (url.startsWith('/api/v1/patient?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'ساغر صابری', user_mobile: '09356619438' }] }); + if (url.startsWith('/api/v1/patients?search=')) return Promise.resolve({ success: true, data: [{ uuid: 'rec1', user_name: 'ساغر صابری', user_mobile: '09356619438', user_national_code: '1234567891' }] }); return Promise.resolve({ success: true, data: [] }); }); post.mockResolvedValue({ success: true, data: { uuid: 'new1' } }); @@ -52,6 +52,7 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { renderDrawer(); fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } }); await screen.findByRole('option', { name: 'زیبایی' }); fireEvent.change(screen.getByLabelText('بخش'), { target: { value: 'sec1' } }); await screen.findByRole('option', { name: 'لیزر توتال' }); @@ -64,6 +65,7 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { doctor_uuid: 'd1', patient_name: 'مریم خلیلی', patient_mobile: '09136549874', + patient_national_code: '1234567891', service_section_uuid: 'sec1', service_item_uuid: 'it1', staff_uuid: 'st1', @@ -77,7 +79,7 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { fireEvent.click(await screen.findByText('ساغر صابری')); fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({ - patient_name: 'ساغر صابری', patient_mobile: '09356619438', + patient_name: 'ساغر صابری', patient_mobile: '09356619438', patient_national_code: '1234567891', }))); }); @@ -97,6 +99,7 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } }); fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); const day = Math.floor(new Date('2026-08-01T00:00').getTime() / 1000); diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index 48f389b0..c46575fe 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -10,7 +10,7 @@ import PriceInput from './ui/PriceInput'; import { WalletChargeLink } from './AppointmentActions'; interface Option { uuid: string; name?: string; full_name?: string } -interface PatientRow { uuid: string; user_name?: string; user_mobile?: string } +interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } const toEpoch = (isoDate: string, time: string) => Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); @@ -41,10 +41,11 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey const [pickedPatient, setPickedPatient] = useState(null); const [name, setName] = useState(''); const [mobile, setMobile] = useState(''); + const [nationalCode, setNationalCode] = useState(''); const patientsQ = useQuery>({ queryKey: ['drawer-patients', patientSearch], - queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`), + queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`), enabled: patientSearch.trim().length >= 2, }); @@ -80,7 +81,9 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey const effectiveName = pickedPatient?.user_name || name.trim(); const effectiveMobile = pickedPatient?.user_mobile || mobile.trim(); - const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && (isReserve || (!!start && !!end)); + const effectiveNationalCode = (pickedPatient?.user_national_code || nationalCode).replace(/\D/g, ''); + const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 + && effectiveNationalCode.length === 10 && (isReserve || (!!start && !!end)); const create = useMutation({ mutationFn: async () => { @@ -90,6 +93,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey slot_end: isReserve ? toEpoch(date, '00:00') : toEpoch(date, end), patient_name: effectiveName, patient_mobile: effectiveMobile, + patient_national_code: effectiveNationalCode, is_reserve: isReserve, ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), ...(itemUuid ? { service_item_uuid: itemUuid } : {}), @@ -153,6 +157,11 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" />
+ +
+ setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))} + placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" maxLength={10} /> +
)} diff --git a/assets/admin/pages/AppointmentCreatePage.test.tsx b/assets/admin/pages/AppointmentCreatePage.test.tsx index 2cfd6de2..f0d2e086 100644 --- a/assets/admin/pages/AppointmentCreatePage.test.tsx +++ b/assets/admin/pages/AppointmentCreatePage.test.tsx @@ -28,13 +28,14 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); fireEvent.click(screen.getByText('ثبت اطلاعات')); await waitFor(() => expect(post).toHaveBeenCalled()); const [url, body] = post.mock.calls[0]; expect(url).toBe('/api/v1/my/appointment'); - expect(body).toMatchObject({ doctor_uuid: 'doc1', patient_name: 'علی محمدی', patient_mobile: '09121234567' }); + expect(body).toMatchObject({ doctor_uuid: 'doc1', patient_name: 'علی محمدی', patient_mobile: '09121234567', patient_national_code: '1234567891' }); }); it('keeps the submit button disabled until a valid patient is entered (boundary)', () => { @@ -46,5 +47,10 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'ب' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '0912' } }); expect(btn).toBeDisabled(); + + // نام و موبایل معتبر ولی کد ملی خالی → همچنان غیرفعال (کد ملی الزامی است) + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + expect(btn).toBeDisabled(); }); }); diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index a771527e..4b61cb0d 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -18,7 +18,7 @@ import { WalletChargeLink } from '../components/AppointmentActions'; */ interface Option { uuid: string; name?: string; full_name?: string } -interface PatientRow { uuid: string; user_name?: string; user_mobile?: string } +interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } const toEpoch = (isoDate: string, time: string) => Math.floor(new Date(`${isoDate}T${time || '00:00'}`).getTime() / 1000); @@ -56,9 +56,10 @@ export default function AppointmentCreatePage() { const [picked, setPicked] = useState(null); const [name, setName] = useState(''); const [mobile, setMobile] = useState(''); + const [nationalCode, setNationalCode] = useState(''); const patientsQ = useQuery>({ queryKey: ['create-patients', patientSearch], - queryFn: () => api.get(`/api/v1/patient?search=${encodeURIComponent(patientSearch)}&limit=10`), + queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`), enabled: patientSearch.trim().length >= 2, }); const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); @@ -90,7 +91,9 @@ export default function AppointmentCreatePage() { 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; + const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, ''); + const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 + && effectiveNationalCode.length === 10 && !!start && !!end; const create = useMutation({ mutationFn: async () => { @@ -101,6 +104,7 @@ export default function AppointmentCreatePage() { slot_end: toEpoch(date, end), patient_name: effectiveName, patient_mobile: effectiveMobile, + patient_national_code: effectiveNationalCode, ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), ...(itemUuid ? { service_item_uuid: itemUuid } : {}), ...(staffUuid ? { staff_uuid: staffUuid } : {}), @@ -193,6 +197,13 @@ export default function AppointmentCreatePage() { setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده" dir="ltr" /> +
+ +
+ setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))} + placeholder="کد ملی مراجعه کننده" dir="ltr" inputMode="numeric" maxLength={10} /> +
+
)} diff --git a/docs/api/admin.md b/docs/api/admin.md index 0bbad7d0..ce229917 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -598,11 +598,12 @@ Create a new appointment for a patient. If no user exists with the given mobile, "slot_end": 1718439600, "patient_mobile": "09123456789", "patient_name": "علی محمدی", + "patient_national_code": "0012345678", "note": "optional note" } ``` -> `patient_mobile` و `patient_name` هر دو اجباری هستند. اگر کاربری با این شماره موبایل نداشته باشیم، یک کاربر جدید با نقش `ROLE_USER` ساخته می‌شود. +> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد. بیمار **اول با کد ملی** و سپس با موبایل resolve می‌شود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص می‌تواند چند موبایل داشته باشد). اگر کاربری یافت نشود، کاربر جدید با نقش `ROLE_USER` و همان کد ملی ساخته می‌شود. ### Response `201` ```json @@ -620,7 +621,8 @@ Create a new appointment for a patient. If no user exists with the given mobile, ### Error Responses | Code | HTTP | Description | |------|------|-------------| -| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name) | +| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid `patient_national_code` (`field: patient_national_code`) | +| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) | | `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found | | `SLOT_TAKEN` | 409 | Slot already booked | diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 65808765..f368c1dd 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -397,11 +397,18 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book "slot_end": 1718439600, "patient_mobile": "09123456789", "patient_name": "علی محمدی", + "patient_national_code": "0012345678", "note": "optional note" } ``` -> اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش `ROLE_USER` ساخته می‌شود. +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| `patient_mobile` | string | ✅ | راه تماس بیمار | +| `patient_name` | string | ✅ | نام بیمار | +| `patient_national_code` | string | ✅ | کد ملی بیمار — باید ۱۰ رقم معتبر باشد (`isValidIranNationalCode`)؛ ارقام فارسی به انگلیسی تبدیل می‌شوند | + +> **هویت بیمار بر پایه‌ی کد ملی:** بیمار **اول با کد ملی** پیدا می‌شود، سپس با موبایل. کد ملی یکتاست (`User.national_code unique`)، پس یک شخص می‌تواند چند موبایل داشته باشد ولی پرونده‌اش (`PatientRecord`) یکتا می‌ماند. اگر موبایلی که قبلاً با کد ملی دیگری ثبت شده دوباره با کد ملی متفاوت ارسال شود، خطای 422 برمی‌گردد. اگر هیچ کاربری یافت نشود، کاربر جدید با نقش `ROLE_USER` و همان کد ملی ساخته می‌شود. ### Response `201` ```json @@ -420,7 +427,8 @@ Create a new appointment for a patient. Used by doctor/clinic/secretary to book | Code | HTTP | Description | |------|------|-------------| | `FORBIDDEN` | 403 | Role not allowed, or caller not scoped to this doctor | -| `VALIDATION` | 422 | Missing required fields | +| `VALIDATION` | 422 | Missing required fields, or missing/invalid `patient_national_code` (`field: patient_national_code`) | +| `ERR_PROFILE_MOBILE_TAKEN` | 422 | این شماره موبایل با کد ملی دیگری ثبت شده است (`field: patient_mobile`) | | `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found | | `SLOT_TAKEN` | 409 | Slot already booked | diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 1b521b11..fc49fcd0 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -41,6 +41,7 @@ class AdminApiController extends BaseController private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup, private readonly \App\Payment\Service\PaymentManager $paymentManager, private readonly \App\Payment\Repository\PaymentRepository $paymentRepo, + private readonly \App\Patient\Service\PatientResolver $patientResolver, ) {} // ── Users ───────────────────────────────────────────────────────────────── @@ -857,25 +858,30 @@ class AdminApiController extends BaseController $doctorUuid = trim($data['doctor_uuid'] ?? ''); $slotStart = (int) ($data['slot_start'] ?? 0); $slotEnd = (int) ($data['slot_end'] ?? 0); - $mobile = trim($data['patient_mobile'] ?? ''); + $mobile = InputValidator::toEnglishDigits(trim($data['patient_mobile'] ?? '')); $patientName = trim($data['patient_name'] ?? ''); + $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422); } + if ($nationalCode === '') { + return $this->error(ErrorCodes::VALIDATION, 'کد ملی بیمار الزامی است', 422, 'patient_national_code'); + } + if (!InputValidator::isValidIranNationalCode($nationalCode)) { + return $this->error(ErrorCodes::VALIDATION, 'کد ملی نامعتبر است', 422, 'patient_national_code'); + } + $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]); if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404); - $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); - } + // Identity is keyed on the national code (unique) so the case-file stays + // single per person even when booked under a different mobile. + $patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName); $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); + $appointment->setPatientNationalCode($nationalCode); if (!empty($data['note'])) $appointment->setNote($data['note']); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); if ($locationId !== null) $appointment->setAddressId($locationId); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index aea0a948..9268c62c 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -12,9 +12,11 @@ use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Entity\Doctor; use App\Doctor\Repository\DoctorRepository; +use App\Patient\Service\PatientResolver; use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Controller\BaseController; +use App\Shared\Service\InputValidator; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -37,6 +39,7 @@ class MyAppointmentsController extends BaseController private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo, private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo, + private readonly PatientResolver $patientResolver, ) {} #[Route('/api/v1/my/appointment', methods: ['POST'])] @@ -53,8 +56,9 @@ class MyAppointmentsController extends BaseController $doctorUuid = trim($data['doctor_uuid'] ?? ''); $slotStart = (int) ($data['slot_start'] ?? 0); $slotEnd = (int) ($data['slot_end'] ?? 0); - $mobile = trim($data['patient_mobile'] ?? ''); + $mobile = InputValidator::toEnglishDigits(trim($data['patient_mobile'] ?? '')); $patientName = trim($data['patient_name'] ?? ''); + $nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? ''))); $isReserve = (bool) ($data['is_reserve'] ?? false); // Reserve entries are day-level: only a date is picked in the UI, so @@ -67,6 +71,13 @@ class MyAppointmentsController extends BaseController return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422); } + if ($nationalCode === '') { + return $this->error(ErrorCodes::VALIDATION, 'کد ملی بیمار الزامی است', 422, 'patient_national_code'); + } + if (!InputValidator::isValidIranNationalCode($nationalCode)) { + return $this->error(ErrorCodes::VALIDATION, 'کد ملی نامعتبر است', 422, 'patient_national_code'); + } + if (!$isReserve && $slotStart < time()) { return $this->error(ErrorCodes::SLOT_PAST, 'زمان این اسلات گذشته است', 422); } @@ -78,15 +89,12 @@ class MyAppointmentsController extends BaseController return $this->error(ErrorCodes::FORBIDDEN, 'برای این پزشک مجاز به ثبت نوبت نیستید', 403); } - $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); - } + // Identity is keyed on the national code (unique) so the case-file stays + // single per person even when booked under a different mobile. + $patient = $this->patientResolver->resolveForBooking($nationalCode, $mobile, $patientName); $appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd); + $appointment->setPatientNationalCode($nationalCode); if (!empty($data['note'])) $appointment->setNote($data['note']); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart); if ($locationId !== null) $appointment->setAddressId($locationId); diff --git a/src/Auth/Repository/UserRepository.php b/src/Auth/Repository/UserRepository.php index ef059888..10c5391d 100644 --- a/src/Auth/Repository/UserRepository.php +++ b/src/Auth/Repository/UserRepository.php @@ -18,6 +18,11 @@ class UserRepository extends ServiceEntityRepository return $this->findOneBy(['mobileNumber' => $mobile]); } + public function findByNationalCode(string $nationalCode): ?User + { + return $this->findOneBy(['nationalCode' => $nationalCode]); + } + public function findByUuid(string $uuid): ?User { return $this->findOneBy(['uuid' => $uuid]); diff --git a/src/Patient/Service/PatientResolver.php b/src/Patient/Service/PatientResolver.php new file mode 100644 index 00000000..3f64dd51 --- /dev/null +++ b/src/Patient/Service/PatientResolver.php @@ -0,0 +1,62 @@ +userRepo->findByNationalCode($nationalCode); + if ($user !== null) { + $this->fillNameIfEmpty($user, $name); + return $user; + } + + $user = $this->userRepo->findByMobile($mobile); + if ($user !== null) { + $existing = $user->getNationalCode(); + if ($existing !== null && $existing !== $nationalCode) { + throw new AppException(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN, 'این شماره موبایل با کد ملی دیگری ثبت شده است', 422, 'patient_mobile'); + } + if ($existing === null) { + $user->setNationalCode($nationalCode); + } + $this->fillNameIfEmpty($user, $name); + return $user; + } + + $user = new User($mobile); + $user->setRealName($name); + $user->setNationalCode($nationalCode); + $user->setRoles(['ROLE_USER']); + $this->userRepo->save($user, false); + + return $user; + } + + private function fillNameIfEmpty(User $user, string $name): void + { + if (($user->getRealName() ?? '') === '' && $name !== '') { + $user->setRealName($name); + } + } +} diff --git a/tests/Appointment/AppointmentCreateReserveTest.php b/tests/Appointment/AppointmentCreateReserveTest.php index c6b8b913..3081ba73 100644 --- a/tests/Appointment/AppointmentCreateReserveTest.php +++ b/tests/Appointment/AppointmentCreateReserveTest.php @@ -44,6 +44,7 @@ class AppointmentCreateReserveTest extends ApiTestCase 'slot_end' => $start + 2_400, 'patient_mobile' => '09' . random_int(100000000, 999999999), 'patient_name' => 'مریم اسکندری', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), 'service_section_uuid' => $section->getUuid(), 'service_item_uuid' => $item->getUuid(), 'staff_uuid' => $staff->getUuid(), @@ -73,6 +74,7 @@ class AppointmentCreateReserveTest extends ApiTestCase 'slot_end' => $day, 'patient_mobile' => '09' . random_int(100000000, 999999999), 'patient_name' => $name, + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), 'is_reserve' => true, ]); self::assertSame(201, $this->responseCode()); @@ -98,6 +100,7 @@ class AppointmentCreateReserveTest extends ApiTestCase 'slot_end' => $start + 1_800, 'patient_mobile' => '09' . random_int(100000000, 999999999), 'patient_name' => 'x', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), 'service_item_uuid' => 'missing-uuid', ]); self::assertSame(422, $this->responseCode()); diff --git a/tests/Appointment/AppointmentNationalCodeTest.php b/tests/Appointment/AppointmentNationalCodeTest.php new file mode 100644 index 00000000..3803e2a7 --- /dev/null +++ b/tests/Appointment/AppointmentNationalCodeTest.php @@ -0,0 +1,118 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + return [$owner, $doctor]; + } + + private function nationalCode(): string + { + return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT); + } + + private function mobile(): string + { + return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT); + } + + private function body(string $doctorUuid, string $mobile, string $nationalCode): array + { + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + return [ + 'doctor_uuid' => $doctorUuid, + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => $mobile, + 'patient_name' => 'بیمار تست', + 'patient_national_code' => $nationalCode, + ]; + } + + public function testCreatesUserWithNationalCode(): void + { + [$owner, $doctor] = $this->doctor(); + $nc = $this->nationalCode(); + + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc)); + + self::assertSame(201, $this->responseCode()); + $patient = $this->em->getRepository(User::class)->findOneBy(['nationalCode' => $nc]); + self::assertNotNull($patient); + self::assertSame($nc, $patient->getNationalCode()); + } + + public function testSameNationalCodeDifferentMobileReusesSinglePatient(): void + { + [$owner, $doctor] = $this->doctor(); + $nc = $this->nationalCode(); + + // First booking under mobile A creates the patient. + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc)); + self::assertSame(201, $this->responseCode()); + + // Second booking under a *different* mobile but the same national code + // must resolve to the same patient — the case-file stays unique. + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc)); + self::assertSame(201, $this->responseCode()); + + $patients = $this->em->getRepository(User::class)->findBy(['nationalCode' => $nc]); + self::assertCount(1, $patients); + } + + public function testMissingNationalCodeIs422(): void + { + [$owner, $doctor] = $this->doctor(); + $body = $this->body($doctor->getUuid(), $this->mobile(), $this->nationalCode()); + unset($body['patient_national_code']); + + $this->authJson('POST', '/api/v1/my/appointment', $owner, $body); + + self::assertSame(422, $this->responseCode()); + } + + public function testInvalidNationalCodeIs422(): void + { + [$owner, $doctor] = $this->doctor(); + + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), '123')); + + self::assertSame(422, $this->responseCode()); + } + + public function testMobileBelongingToAnotherNationalCodeIsRejected(): void + { + [$owner, $doctor] = $this->doctor(); + $mobile = $this->mobile(); + + // First booking binds this mobile to national code A. + $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $mobile, $this->nationalCode())); + self::assertSame(201, $this->responseCode()); + + // Same mobile, a *different* national code → identity conflict. + $res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $mobile, $this->nationalCode())); + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN, $res['errors'][0]['code']); + } +} diff --git a/tests/Appointment/BookingScopeTest.php b/tests/Appointment/BookingScopeTest.php index 361bcd7a..04731d37 100644 --- a/tests/Appointment/BookingScopeTest.php +++ b/tests/Appointment/BookingScopeTest.php @@ -27,11 +27,12 @@ class BookingScopeTest extends ApiTestCase $start = time() + 86_400; return [ - 'doctor_uuid' => $doctorUuid, - 'slot_start' => $start, - 'slot_end' => $start + 1_800, - 'patient_mobile' => '09120000000', - 'patient_name' => 'بیمار تست', + 'doctor_uuid' => $doctorUuid, + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), ]; }