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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user