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

20 KiB
Raw Blame History

رفع باگ‌ها و افزودن قابلیت‌های جدید — 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 را به ۱۵ افزایش بده.

// در 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 هم بپذیرد:

// اگر 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 دکتری ندارد → پیام «ابتدا یک پزشک به کلینیک اضافه کنید».

// در 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 استفاده کن:

// در 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 اضافه شود:

#[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 را پشتیبانی کند:

// 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 وجود دارد؟ اگر نه، اضافه‌اش کن:

public function findActiveByUser(User $user): ?DoctorSecretary
{
    return $this->findOneBy(['secretary' => $user, 'active' => true]);
}

قابلیت ۵ — فرمت قیمت با کاما در فرم‌ها

در فرم‌های input قیمت (ClinicServicesPage، SmsWalletPage، SubscriptionPage)، وقتی کاربر عدد می‌زند باید با کاما نمایش داده شود، ولی مقدار واقعی (number) ذخیره شود.

راه‌حل: یک component PriceInput در assets/admin/components/ui/ بساز:

// 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:

// جایگزین کردن
<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 را گسترش بده:
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
];
  1. MellatGateway و SepGateway را تغییر بده تا از SiteConfigRepository بخوانند به جای env:
// 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
  1. یک Mock Gateway (درگاه تست) اضافه کن:
// 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());
    }
}
  1. در PaymentController (یا جایی که gateway انتخاب می‌شود)، وقتی payment_test_mode = true در config، از MockGateway استفاده کن.

Frontend — صفحه جدید SettingsPage یا بخشی از SettingsPage:

فعلاً SettingsPage.tsx در assets/admin/pages/ وجود دارد. بخش «درگاه پرداخت» را به آن اضافه کن:

// در 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 سه فیلد جدید اضافه کن:

#[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 جدید اضافه کن:

#[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:

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:

// بعد از 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