- 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.
20 KiB
رفع باگها و افزودن قابلیتهای جدید — 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 طول ۱۰ دارد ولی کد ملی ۱۱ رقم فارسی ارسال شده. باید:
- طول column به ۲۰ افزایش یابد (برای ارقام فارسی که ۲بایتی هستند)
- یا ارقام فارسی به لاتین تبدیل شوند قبل از ذخیره
راهحل: ارقام فارسی را در 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:
- کلیدهای پرداخت را در
SiteConfigذخیره کن (نه .env) 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
];
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
- یک 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());
}
}
- در
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: togglemellat_terminal_id,mellat_username,mellat_password: text fields (password masked)sep_terminal_id: text fieldsms_provider: select (kavenegar / mock)kavenegar_api_key: text fieldkavenegar_sender: text fieldsms_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 |
نکات مهم
- ترتیب اجرا: ابتدا باگها (۱→۲→۳→۴)، بعد قابلیتها (۵→۶→۷)
- Migration لازم است برای:
ClinicStaff.national_code(length)،SmsSettings(سه فیلد جدید) payment_test_mode: وقتی true است، gateway selector در SmsWalletPage بایدmockرا هم نشان بدهد- Gateway از config: اگر config خالی بود → fallback به env variable (backward compatible)
SiteConfigRepository::getAll(): مراقب باش کلیدهای حساس (password، api_key) را در response برنگردانی — یا mask کن (مثلاً***نشان بده) یا از GET response حذف کن- approval flow: فقط
postVisitText(approved) در ارسال SMS واقعی استفاده شود؛postVisitTextPendingهرگز مستقیم ارسال نشود - PatientController + secretary: secretary فقط اگر
permissions.appointments.view = trueباشد میتواند پرونده ببیند — بررسی کنpatient_recordsfeature هم در subscription secretary لازم است یا نه formatRialموجود است درutils.tsو ازIntl.NumberFormat('fa-IR')استفاده میکند —PriceInputهم باید همین format را داشته باشد- کلید
toLatinDigits: این helper را درutils.tsexport کن تا در backend هم بتوانیم از آن الهام بگیریم (PHP version) - docs/api باید update شود برای هر endpoint جدید/تغییریافته:
docs/api/staff.md،docs/api/secretary.md،docs/api/admin.md،docs/api/sms.md