feat(appointment): identify admin-booked patient by national code

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>
This commit is contained in:
hamed
2026-07-15 18:11:40 +03:30
co-authored by Claude Opus 4.8
parent 141ce478a2
commit 5548d79d4c
14 changed files with 430 additions and 34 deletions
@@ -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 طبق قاعده‌ی پروژه).
@@ -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);
@@ -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<PatientRow | null>(null);
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const [nationalCode, setNationalCode] = useState('');
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
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
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" />
</div>
<label style={label}>کد ملی</label>
<div className="field" style={{ margin: '6px 0 12px' }}>
<input value={nationalCode} onChange={e => setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))}
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" maxLength={10} />
</div>
</>
)}
@@ -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();
});
});
+14 -3
View File
@@ -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<PatientRow | null>(null);
const [name, setName] = useState('');
const [mobile, setMobile] = useState('');
const [nationalCode, setNationalCode] = useState('');
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
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() {
<input value={mobile} onChange={e => setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده" dir="ltr" />
</div>
</div>
<div>
<label style={label}>کد ملی</label>
<div className="field" style={{ marginTop: 6 }}>
<input value={nationalCode} onChange={e => setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))}
placeholder="کد ملی مراجعه کننده" dir="ltr" inputMode="numeric" maxLength={10} />
</div>
</div>
</div>
</>
)}
+4 -2
View File
@@ -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 |
+10 -2
View File
@@ -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 |
+14 -8
View File
@@ -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);
@@ -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);
+5
View File
@@ -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]);
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Patient\Service;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* Resolves (or creates) the patient User for an admin-side booking.
*
* Identity key is the national code, which is unique per person. Mobile is only
* a contact detail — one national code may be booked under several mobiles — so
* lookup prefers the national code and never overwrites an existing mobile.
* Keeping resolution here (not duplicated in each controller) keeps the case-file
* (PatientRecord, keyed on user_id) unique per national code.
*/
class PatientResolver
{
public function __construct(private readonly UserRepository $userRepo) {}
/**
* @param string $nationalCode already normalized to English digits and validated
*/
public function resolveForBooking(string $nationalCode, string $mobile, string $name): User
{
$user = $this->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);
}
}
}
@@ -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());
@@ -0,0 +1,118 @@
<?php
namespace App\Tests\Appointment;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Tests\ApiTestCase;
/**
* POST /api/v1/my/appointment must identify the patient by national code:
* it is required + validated, and the patient User (hence the case-file) is
* resolved by national code first so one person keeps a single record even
* when booked under a different mobile.
*/
class AppointmentNationalCodeTest extends ApiTestCase
{
/** @return array{0: User, 1: Doctor} */
private function doctor(): array
{
$owner = $this->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']);
}
}
+6 -5
View File
@@ -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),
];
}