# رفع باگ‌ها و افزودن قابلیت‌های جدید — Phase 3 ## زمینه چندین باگ و قابلیت جدید شناسایی شده‌اند که باید در یک session رفع/پیاده‌سازی شوند: - باگ‌های backend روی staff/secretary/appointments - قابلیت‌های جدید: مدیریت درگاه پرداخت و SMS از پنل ادمین، post-visit text approval flow --- ## مشکل / هدف ### باگ ۱ — خطای 500 در ایجاد پرسنل (national_code > 10 chars) `POST /api/v1/staff` با `national_code: "۴۲۳۲۰۵۱۰۹۸۷"` → HTTP 500 **ریشه:** `ClinicStaff.nationalCode` column طول ۱۰ دارد ولی کد ملی ۱۱ رقم فارسی ارسال شده. باید: 1. طول column به ۲۰ افزایش یابد (برای ارقام فارسی که ۲بایتی هستند) 2. یا ارقام فارسی به لاتین تبدیل شوند قبل از ذخیره **راه‌حل:** ارقام فارسی را در controller به لاتین تبدیل کن + طول column را به ۱۵ افزایش بده. ```php // در StaffController::create و update $data['national_code'] = $this->toLatinDigits($data['national_code'] ?? null); private function toLatinDigits(?string $str): ?string { if ($str === null) return null; return strtr($str, ['۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9', '٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']); } ``` ### باگ ۲ — doctor_uuid: null در ایجاد منشی از پنل کلینیک `POST /api/v1/secretary` با `{mobile_number: "09...", doctor_uuid: null}` → HTTP 422 **ریشه:** `MySecretariesPage.tsx` از `useAuthStore().doctorUuid` استفاده می‌کند. وقتی کاربر `clinic` است، `doctorUuid` در auth store null است چون clinic دارای uuid پزشک نیست. **جریان فعلی:** `SecretaryController::create` فقط doctor_uuid می‌پذیرد و clinic نمی‌تواند منشی اضافه کند. **راه‌حل — دو بخش:** **الف — Backend:** `SecretaryController::create` باید clinic_uuid هم بپذیرد: ```php // اگر doctor_uuid خالی بود و clinic_uuid داشتیم، منشی برای کلینیک ایجاد شود $doctorUuid = trim($data['doctor_uuid'] ?? ''); $clinicUuid = trim($data['clinic_uuid'] ?? ''); if (!empty($clinicUuid) && empty($doctorUuid)) { // منشی برای clinic — از ClinicRepository پیدا کن $clinic = $this->clinicRepo->findByUuid($clinicUuid); if ($clinic === null) return $this->error(...); // clinic می‌تواند منشی داشته باشد — DoctorSecretary نیست، باید entity جدید یا متفاوت باشد } ``` **توجه مهم:** `DoctorSecretary` entity مستقیماً به `Doctor` وصل است. برای clinic باید بررسی کنی: - آیا `DoctorSecretary` دارای فیلد clinic FK هم هست؟ - اگر نه، باید تصمیم بگیری: یا کلینیک یکی از دکتران خود را انتخاب کند که منشی برای آن تعریف شود، یا یک entity جدید `ClinicSecretary` بسازیم. **پیشنهاد ساده‌تر (frontend):** در `MySecretariesPage.tsx`، وقتی `primaryRole === 'clinic'`، یک dropdown نشان بده که کاربر ابتدا یکی از پزشکان کلینیک را انتخاب کند، سپس منشی برای آن پزشک تعریف شود. این تغییر فقط frontend است و API دست نمی‌خورد. اگر clinic دکتری ندارد → پیام «ابتدا یک پزشک به کلینیک اضافه کنید». ```tsx // در MySecretariesPage.tsx const { dbUuid, primaryRole, doctorUuid } = useAuthStore(); // کلینیک باید پزشک انتخاب کند const [selectedDoctorUuid, setSelectedDoctorUuid] = useState( primaryRole === 'doctor' ? (doctorUuid ?? '') : '' ); // query پزشکان کلینیک (فقط برای clinic) const { data: clinicDoctorsData } = useQuery({ queryKey: ['clinic-doctors-for-secretary', dbUuid], queryFn: () => api.get(`/api/v1/appointments/clinic-doctors/${dbUuid}`), // endpoint موجود را بررسی کن enabled: primaryRole === 'clinic' && !!dbUuid, }); ``` **بررسی endpoint موجود برای لیست پزشکان کلینیک:** بررسی کن آیا endpoint‌ای برای لیست پزشکان یک کلینیک وجود دارد. اگر نه، باید در AdminApiController یا ClinicController اضافه شود. ### باگ ۳ — در AppointmentsPage پزشکی برای انتخاب نیست (پنل کلینیک) **ریشه:** `doctors` از `appointments` محاسبه می‌شود (unique doctorها از نتایج query). اگر هنوز نوبتی ثبت نشده باشد، لیست پزشکان خالی است پس در `SearchableSelect` چیزی نمایش داده نمی‌شود. **راه‌حل:** یک endpoint برای لیست پزشکان کلینیک بساز و در AppointmentsPage برای role=clinic استفاده کن: ```tsx // در AppointmentsPage.tsx const { data: clinicDoctorsData } = useQuery>({ queryKey: ['clinic-doctors', dbUuid], queryFn: () => api.get(`/api/v1/clinic/${dbUuid}/doctors`), enabled: isClinic && !!dbUuid, }); // merge با doctors از appointments const allDoctors = React.useMemo(() => { const fromAppts = doctors; // موجود const fromClinc = (clinicDoctorsData?.data ?? []).map(d => ({ uuid: d.uuid, name: d.name })); const merged = new Map(); [...fromClinc, ...fromAppts].forEach(d => merged.set(d.uuid, d.name)); return Array.from(merged.entries()).map(([uuid, name]) => ({ uuid, name })); }, [doctors, clinicDoctorsData]); ``` **Backend endpoint جدید:** `GET /api/v1/clinic/{uuid}/doctors` — در `ClinicController` یا `AdminApiController` اضافه شود: ```php #[Route('/api/v1/clinic/{uuid}/doctors', methods: ['GET'])] public function clinicDoctors(string $uuid, #[CurrentUser] User $user): JsonResponse { $clinic = $this->clinicRepo->findByUuid($uuid); // بررسی دسترسی: فقط صاحب clinic یا admin // برگرداندن لیست پزشکان: uuid + نام } ``` ### باگ ۴ — منشی نمی‌تواند پرونده بیمار تشکیل دهد **ریشه:** `PatientController::resolveEntity` فقط `ROLE_DOCTOR` و `ROLE_CLINIC` بررسی می‌کند. برای `ROLE_SECRETARY` عدد null برمی‌گرداند و `assertPatientGate` خطای 403 می‌دهد. **راه‌حل:** `resolveEntity` را در PatientController گسترش بده تا secretary را پشتیبانی کند: ```php // src/Patient/Controller/PatientController.php // اضافه کردن SecretaryRepository به constructor private readonly DoctorSecretaryRepository $secretaryRepo, private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { $doctor = $this->doctorRepo->findByUser($user); return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; } if ($user->hasRole('ROLE_CLINIC')) { $clinic = $this->clinicRepo->findByUser($user); return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; } if ($user->hasRole('ROLE_SECRETARY')) { // secretary دسترسی به entity پزشک خود را دارد اگر permissions.appointments.view = true باشد $secretary = $this->secretaryRepo->findActiveByUser($user); if ($secretary !== null && ($secretary->getPermissions()['appointments']['view'] ?? false)) { return ['doctor', $secretary->getDoctor()->getId()]; } } return ['unknown', null]; } ``` **بررسی `findActiveByUser`:** آیا این متد در `DoctorSecretaryRepository` وجود دارد؟ اگر نه، اضافه‌اش کن: ```php public function findActiveByUser(User $user): ?DoctorSecretary { return $this->findOneBy(['secretary' => $user, 'active' => true]); } ``` --- ### قابلیت ۵ — فرمت قیمت با کاما در فرم‌ها در فرم‌های input قیمت (ClinicServicesPage، SmsWalletPage، SubscriptionPage)، وقتی کاربر عدد می‌زند باید با کاما نمایش داده شود، ولی مقدار واقعی (number) ذخیره شود. **راه‌حل:** یک component `PriceInput` در `assets/admin/components/ui/` بساز: ```tsx // assets/admin/components/ui/PriceInput.tsx interface PriceInputProps { value: number | ''; onChange: (value: number) => void; placeholder?: string; min?: number; } export default function PriceInput({ value, onChange, placeholder, min = 0 }: PriceInputProps) { const [display, setDisplay] = useState(value ? new Intl.NumberFormat('fa-IR').format(Number(value)) : ''); const handleChange = (e: React.ChangeEvent) => { // حذف همه کاراکترهای غیر عددی (فارسی و لاتین) const raw = e.target.value.replace(/[^\d۰-۹٠-٩]/g, ''); // تبدیل به لاتین const latin = raw.replace(/[۰-۹]/g, d => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))); const num = parseInt(latin, 10) || 0; onChange(num); setDisplay(num ? new Intl.NumberFormat('fa-IR').format(num) : ''); }; return ( ); } ``` استفاده در `ClinicServicesPage.tsx`: ```tsx // جایگزین کردن // با itemForm.setValue('price_rials', v)} min={0} placeholder="85,000" /> ``` --- ### قابلیت ۶ — مدیریت درگاه‌های پرداخت از پنل ادمین **Backend:** 1. کلیدهای پرداخت را در `SiteConfig` ذخیره کن (نه .env) 2. `SiteConfigController::ALLOWED_KEYS` را گسترش بده: ```php private const ALLOWED_KEYS = [ // ... موجود ... // درگاه‌های پرداخت 'mellat_terminal_id', 'mellat_username', 'mellat_password', 'sep_terminal_id', 'sep_enabled', 'mellat_enabled', // درگاه SMS 'sms_provider', // kavenegar / mock 'kavenegar_api_key', 'kavenegar_sender', 'sms_price_rials', // درگاه تست 'payment_test_mode', // true/false ]; ``` 3. `MellatGateway` و `SepGateway` را تغییر بده تا از `SiteConfigRepository` بخوانند به جای env: ```php // MellatGateway.php class MellatGateway implements PaymentGatewayInterface { public function __construct( private readonly HttpClientInterface $httpClient, private readonly SiteConfigRepository $configRepo, ) {} private function getTerminalId(): string { return $this->configRepo->get('mellat_terminal_id') ?? $_ENV['MELLAT_TERMINAL_ID'] ?? ''; } // همانند برای username و password ``` 4. یک **Mock Gateway** (درگاه تست) اضافه کن: ```php // src/Payment/Gateway/MockGateway.php class MockGateway implements PaymentGatewayInterface { public function getName(): string { return 'mock'; } public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult { // redirect به callback مستقیم با success $successUrl = $callbackUrl . '&status=success&mock=1&order_id=' . $orderId; return new PaymentInitResult(true, redirectUrl: $successUrl, token: 'mock-' . $orderId); } public function verify(array $callbackData): PaymentVerifyResult { return new PaymentVerifyResult(true, refId: 'MOCK-' . time()); } } ``` 5. در `PaymentController` (یا جایی که gateway انتخاب می‌شود)، وقتی `payment_test_mode = true` در config، از `MockGateway` استفاده کن. **Frontend — صفحه جدید SettingsPage یا بخشی از SettingsPage:** فعلاً `SettingsPage.tsx` در `assets/admin/pages/` وجود دارد. بخش «درگاه پرداخت» را به آن اضافه کن: ```tsx // در SettingsPage.tsx بخش جدید اضافه کن // یا یک صفحه جدید GatewaySettingsPage.tsx بساز const PAYMENT_KEYS = ['mellat_enabled', 'mellat_terminal_id', 'mellat_username', 'mellat_password', 'sep_enabled', 'sep_terminal_id', 'payment_test_mode']; const SMS_KEYS = ['sms_provider', 'kavenegar_api_key', 'kavenegar_sender', 'sms_price_rials']; ``` فیلدها: - `payment_test_mode`: toggle — اگر true، درگاه mock استفاده شود - `mellat_enabled` / `sep_enabled`: toggle - `mellat_terminal_id`, `mellat_username`, `mellat_password`: text fields (password masked) - `sep_terminal_id`: text field - `sms_provider`: select (kavenegar / mock) - `kavenegar_api_key`: text field - `kavenegar_sender`: text field - `sms_price_rials`: number --- ### قابلیت ۷ — approval flow برای post-visit SMS text **وضعیت فعلی:** `SmsSettings.post_visit_text` یک فیلد ساده text است. وقتی doctor/clinic متن می‌نویسد، مستقیم ذخیره و استفاده می‌شود. **هدف:** متن post-visit باید ابتدا توسط ادمین تأیید شود قبل از اینکه در ارسال SMS استفاده شود. **Backend — تغییرات Entity:** در `SmsSettings` سه فیلد جدید اضافه کن: ```php #[ORM\Column(name: 'post_visit_text_pending', type: 'text', nullable: true)] private ?string $postVisitTextPending = null; // متن در انتظار تأیید #[ORM\Column(name: 'post_visit_text_status', type: 'string', length: 20)] private string $postVisitTextStatus = 'none'; // none | pending | approved | rejected #[ORM\Column(name: 'post_visit_text_reject_reason', type: 'text', nullable: true)] private ?string $postVisitTextRejectReason = null; ``` منطق: - وقتی doctor/clinic متن جدید ارسال می‌کند → `postVisitTextPending` ذخیره شود، `postVisitTextStatus = 'pending'`، `postVisitText` (approved) دست نخورد - وقتی ادمین approve می‌کند → `postVisitText = postVisitTextPending`، `postVisitTextStatus = 'approved'` - وقتی ادمین reject می‌کند → `postVisitTextStatus = 'rejected'`، `postVisitTextRejectReason` ذخیره شود **Backend — endpoint ادمین:** در `SmsWalletController` یا یک controller جدید اضافه کن: ```php #[Route('/api/v1/admin/sms/settings/review', methods: ['GET'])] #[IsGranted('ROLE_ADMIN')] public function pendingReviews(Request $request): JsonResponse { // لیست همه sms_settings که post_visit_text_status = 'pending' } #[Route('/api/v1/admin/sms/settings/{id}/approve', methods: ['POST'])] #[IsGranted('ROLE_ADMIN')] public function approvePostVisit(int $id): JsonResponse { // $settings->setPostVisitText($settings->getPostVisitTextPending()); // $settings->setPostVisitTextStatus('approved'); } #[Route('/api/v1/admin/sms/settings/{id}/reject', methods: ['POST'])] #[IsGranted('ROLE_ADMIN')] public function rejectPostVisit(int $id, Request $request): JsonResponse { // $settings->setPostVisitTextStatus('rejected'); // $settings->setPostVisitTextRejectReason($data['reason']); } ``` **Backend — تغییر `updateSettings`:** در `PATCH /api/v1/sms/settings`: ```php if (array_key_exists('post_visit_text', $data)) { // به جای ذخیره مستقیم، در pending بگذار $settings->setPostVisitTextPending($data['post_visit_text']); $settings->setPostVisitTextStatus('pending'); $settings->setPostVisitTextRejectReason(null); // postVisitText (approved) دست نخورد } ``` **Frontend — SmsWalletPage.tsx:** نمایش وضعیت pending/approved/rejected: ```tsx // بعد از textarea متن پیامک {currentSettings.post_visit_text_status === 'pending' && (
متن شما در انتظار تأیید مدیر است
)} {currentSettings.post_visit_text_status === 'approved' && (
✓ متن تأیید شده — در حال استفاده
)} {currentSettings.post_visit_text_status === 'rejected' && (
رد شد: {currentSettings.post_visit_text_reject_reason}
)} ``` **Frontend — بخش ادمین در SmsPage.tsx یا پنل مدیریت:** تب یا بخش جدید «تأیید متن پیامک» در `SmsPage.tsx` اضافه کن که لیست pending reviews را نشان دهد. --- ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Staff/Controller/StaffController.php` | باگ ۱: national_code validation | | `src/Staff/Entity/ClinicStaff.php` | باگ ۱: column length | | `migrations/` | باید migration جدید بسازی | | `src/Secretary/Controller/SecretaryController.php` | باگ ۲: clinic secretary support | | `src/Patient/Controller/PatientController.php` | باگ ۴: secretary resolveEntity | | `src/Patient/Repository/PatientRecordRepository.php` | بررسی findActiveByUser | | `src/Secretary/Repository/DoctorSecretaryRepository.php` | اضافه کردن findActiveByUser | | `src/Clinic/Controller/ClinicController.php` | endpoint لیست پزشکان کلینیک | | `src/Config/Controller/SiteConfigController.php` | قابلیت ۶: allowed keys گسترش | | `src/Payment/Gateway/MellatGateway.php` | قابلیت ۶: از config بخواند | | `src/Payment/Gateway/SepGateway.php` | قابلیت ۶: از config بخواند | | `src/Payment/Gateway/MockGateway.php` | قابلیت ۶: درگاه تست (جدید) | | `src/Sms/Entity/SmsSettings.php` | قابلیت ۷: فیلدهای approval | | `src/Sms/Controller/SmsWalletController.php` | قابلیت ۷: updateSettings تغییر + endpoint ادمین | | `assets/admin/pages/AppointmentsPage.tsx` | باگ ۳: clinic doctors query | | `assets/admin/pages/MySecretariesPage.tsx` | باگ ۲: clinic با انتخاب پزشک | | `assets/admin/pages/SmsWalletPage.tsx` | قابلیت ۷: نمایش وضعیت | | `assets/admin/pages/SettingsPage.tsx` | قابلیت ۶: بخش درگاه‌های پرداخت | | `assets/admin/pages/SmsPage.tsx` | قابلیت ۷: تأیید/رد متن (ادمین) | | `assets/admin/components/ui/PriceInput.tsx` | قابلیت ۵: جدید | | `assets/admin/lib/utils.ts` | قابلیت ۵: `toLatinDigits` helper | --- ## نکات مهم 1. **ترتیب اجرا:** ابتدا باگ‌ها (۱→۲→۳→۴)، بعد قابلیت‌ها (۵→۶→۷) 2. **Migration لازم است** برای: `ClinicStaff.national_code` (length)، `SmsSettings` (سه فیلد جدید) 3. **`payment_test_mode`:** وقتی true است، gateway selector در SmsWalletPage باید `mock` را هم نشان بدهد 4. **Gateway از config:** اگر config خالی بود → fallback به env variable (backward compatible) 5. **`SiteConfigRepository::getAll()`:** مراقب باش کلیدهای حساس (password، api_key) را در response برنگردانی — یا mask کن (مثلاً `***` نشان بده) یا از GET response حذف کن 6. **approval flow:** فقط `postVisitText` (approved) در ارسال SMS واقعی استفاده شود؛ `postVisitTextPending` هرگز مستقیم ارسال نشود 7. **PatientController + secretary:** secretary فقط اگر `permissions.appointments.view = true` باشد می‌تواند پرونده ببیند — بررسی کن `patient_records` feature هم در subscription secretary لازم است یا نه 8. **`formatRial` موجود است** در `utils.ts` و از `Intl.NumberFormat('fa-IR')` استفاده می‌کند — `PriceInput` هم باید همین format را داشته باشد 9. **کلید `toLatinDigits`:** این helper را در `utils.ts` export کن تا در backend هم بتوانیم از آن الهام بگیریم (PHP version) 10. **docs/api باید update شود** برای هر endpoint جدید/تغییریافته: `docs/api/staff.md`، `docs/api/secretary.md`، `docs/api/admin.md`، `docs/api/sms.md`