Files
clinicpro/.claude/prompt/fix-bugs-and-features.md
T
hamed 5cdcec23a9 feat: enhance staff management and payment gateway features
- Fix national code handling in staff creation and updates to support Persian digits.
- Update ClinicStaff entity to allow longer national codes (up to 15 characters).
- Implement support for clinic secretaries in SecretaryController, allowing creation without a doctor UUID.
- Add a new endpoint to retrieve doctors associated with a clinic for secretary management.
- Improve appointment management by ensuring doctors are selectable even when no appointments exist.
- Extend PatientController to allow secretaries to create patient records if they have the appropriate permissions.
- Introduce a PriceInput component for better price formatting in forms, supporting Persian digits.
- Add a MockGateway for testing payment processes without real transactions.
- Enhance SMS settings management with an approval flow for post-visit text messages, including new fields for pending text and status.
- Update migrations to reflect changes in database schema for national codes and SMS settings.
2026-06-15 11:03:56 +03:30

448 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رفع باگ‌ها و افزودن قابلیت‌های جدید — 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<ApiResponse<{uuid: string; name: string}[]>>({
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<string, string>();
[...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<HTMLInputElement>) => {
// حذف همه کاراکترهای غیر عددی (فارسی و لاتین)
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 (
<input
type="text"
inputMode="numeric"
value={display}
onChange={handleChange}
placeholder={placeholder}
dir="ltr"
style={{ textAlign: 'left' }}
/>
);
}
```
استفاده در `ClinicServicesPage.tsx`:
```tsx
// جایگزین کردن
<input {...itemForm.register('price_rials')} type="number" .../>
// با
<PriceInput
value={itemForm.watch('price_rials') ?? 0}
onChange={(v) => 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' && (
<div style={{ background: 'oklch(0.98 0.04 60)', border: '1px solid oklch(0.85 0.1 60)', borderRadius: 8, padding: '8px 12px', fontSize: 12.5, color: 'oklch(0.5 0.15 60)' }}>
متن شما در انتظار تأیید مدیر است
</div>
)}
{currentSettings.post_visit_text_status === 'approved' && (
<div style={{ background: 'var(--primary-subtle)', ... }}>
متن تأیید شده در حال استفاده
</div>
)}
{currentSettings.post_visit_text_status === 'rejected' && (
<div style={{ background: '#fef2f2', ... }}>
رد شد: {currentSettings.post_visit_text_reject_reason}
</div>
)}
```
**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`