From 1804b4215da18809922efddb0ce3fc3e8c2b4ce8 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 2 Jul 2026 21:11:17 +0330 Subject: [PATCH] feat(sms): add doctor appointment notification SMS for paid appointments --- .../doctor-appointment-notification-sms.md | 141 ++++++++++++++++++ docs/api/sms.md | 4 +- src/Payment/Service/PaymentManager.php | 11 ++ src/Sms/Entity/SmsLog.php | 2 + src/Sms/Entity/SmsMessageTemplate.php | 5 + 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 .claude/prompt/doctor-appointment-notification-sms.md diff --git a/.claude/prompt/doctor-appointment-notification-sms.md b/.claude/prompt/doctor-appointment-notification-sms.md new file mode 100644 index 00000000..15c85f54 --- /dev/null +++ b/.claude/prompt/doctor-appointment-notification-sms.md @@ -0,0 +1,141 @@ +# پیامک اعلان نوبت جدید به «شمارهٔ اعلان» دکتر (فقط نوبت‌های پرداخت‌شدهٔ سایت) + +## پروژه + +`clinicpro` (backend / Payment + Sms) + +## زمینه + +هر دکتر در `/admin/profile` یک «شمارهٔ اعلان نوبت» (`Doctor::notificationMobile`) ست می‌کند («پیامک نوبت جدید به این شماره ارسال می‌شود»). این فیلد و تگ `SmsLog::TAG_NOTIFICATION_MOBILE` وجود دارند، ولی تگ فعلی فقط برای **تأیید ثبت شماره** (کد تأیید در `NotificationMobileController`) استفاده می‌شود — به رویداد «نوبت جدید» وصل نیست. + +الان هنگام موفقیت پرداخت نوبت، فقط به **بیمار** پیامک می‌رود (`PaymentManager::handleAppointmentConfirmation` با تگ `TAG_PAYMENT`). هدف: علاوه بر آن، به **شمارهٔ اعلانِ دکتر** هم یک پیامک با قالب مشخص (شامل نام بیمار و ساعت نوبت) ارسال شود. + +**فقط نوبت‌های پرداخت‌شده از سایت** باید این پیامک را بدهند — نه نوبت‌هایی که منشی ثبت می‌کند. چون `handleAppointmentConfirmation` **فقط** در مسیر موفقیت پرداخت (`PaymentManager::processCallback` → `runPostAction`) اجرا می‌شود و نوبت‌های ثبت‌شده توسط منشی از این مسیر عبور نمی‌کنند، افزودن کد در همین‌جا به‌طور طبیعی شرط «فقط سایت/پرداخت» را رعایت می‌کند. + +## مشکل / هدف + +هنگام تأیید پرداختِ یک نوبت، اگر دکترِ آن نوبت `notificationMobile` ست کرده باشد، یک پیامک با تگ/قالب اختصاصی (`TAG_DOCTOR_APPOINTMENT`) شامل `{patient}` و `{date}` و `{time}` به آن شماره ارسال شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `clinicpro/src/Sms/Entity/SmsLog.php` | افزودن تگ `TAG_DOCTOR_APPOINTMENT` + به آرایهٔ `TAGS` | +| `clinicpro/src/Sms/Entity/SmsMessageTemplate.php` | افزودن قالب پیش‌فرض این تگ به `DEFAULTS` | +| `clinicpro/src/Payment/Service/PaymentManager.php` | ارسال پیامک به `notificationMobile` دکتر در `handleAppointmentConfirmation` | +| `clinicpro/docs/api/sms.md` | مستندسازی تگ/قالب جدید | + +## وضعیت فعلی + +### `PaymentManager::handleAppointmentConfirmation` (فقط پیامک بیمار) + +```php +private function handleAppointmentConfirmation(Payment $payment): void +{ + $appointment = $payment->getAppointment(); + if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) { + return; + } + + $appointment->transitionTo(Appointment::STATUS_CONFIRMED); + $this->em->persist($appointment); + + $doctor = $appointment->getDoctor(); + $this->commissionService->processAppointment( + $payment, + $doctor->getRepresentationId(), + $appointment->getBookingRepresentationId(), + $doctor->getId(), + ); + + $mobile = $appointment->getPatientMobile(); + if ($mobile) { + $when = $this->jalali->formatDateTime($appointment->getSlotStart()); + $message = $this->smsText->resolve(SmsLog::TAG_PAYMENT, [ + 'doctor' => $doctor->getName(), + 'date' => $when, + ]); + $this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT); + } +} +``` + +### مقادیر در دسترس + +- `Doctor::getNotificationMobile(): ?string` (فیلد `notification_mobile`, nullable) +- `Doctor::getName()` +- `Appointment::getPatientName(): ?string`، `getPatientMobile()`، `getSlotStart(): int` (unix) +- `JalaliDateService::formatDateTime(int $ts, bool $withTime = true): string` — `false` = فقط تاریخ شمسی. متد جدا برای «ساعت» ندارد؛ ساعت را با `date('H:i', $slotStart)` بگیر. + +### الگوی تگ‌ها (SmsLog) + +```php +public const TAG_NOTIFICATION_MOBILE = 'notification_mobile'; +public const TAGS = [ self::TAG_GLOBAL, self::TAG_OTP, self::TAG_PAYMENT, /* ... */ self::TAG_SECRETARY ]; +``` + +### الگوی قالب‌ها (SmsMessageTemplate::DEFAULTS) + +```php +SmsLog::TAG_PAYMENT => [ + 'title' => 'تأیید پرداخت و نوبت', + 'body' => 'نوبت شما با {doctor} در تاریخ {date} ثبت و تأیید شد.', + 'variables' => ['doctor', 'date'], +], +``` + +## وظایف + +### ۱. تگ جدید در `SmsLog` + +```php +public const TAG_DOCTOR_APPOINTMENT = 'doctor_appointment'; +``` +و آن را به آرایهٔ `TAGS` اضافه کن (تا در پنل مدیریت قالب‌ها و seed شناخته شود). + +### ۲. قالب پیش‌فرض در `SmsMessageTemplate::DEFAULTS` + +```php +SmsLog::TAG_DOCTOR_APPOINTMENT => [ + 'title' => 'نوبت جدید (اعلان به پزشک)', + 'body' => "نوبت جدید ثبت شد.\nبیمار: {patient}\nتاریخ: {date} ساعت {time}", + 'variables' => ['patient', 'date', 'time'], +], +``` + +### ۳. ارسال پیامک به شمارهٔ اعلان دکتر در `handleAppointmentConfirmation` + +بعد از بلوک پیامک بیمار (داخل همان متد)، اضافه کن: + +```php +$notify = $doctor->getNotificationMobile(); +if ($notify) { + $docMessage = $this->smsText->resolve(SmsLog::TAG_DOCTOR_APPOINTMENT, [ + 'patient' => $appointment->getPatientName() ?? '—', + 'date' => $this->jalali->formatDateTime($appointment->getSlotStart(), false), + 'time' => date('H:i', $appointment->getSlotStart()), + ]); + $this->smsService->dispatchAsync($notify, $docMessage, tag: SmsLog::TAG_DOCTOR_APPOINTMENT); +} +``` + +### ۴. seed قالب جدید + +بعد از افزودن به `DEFAULTS`، دستور موجود را اجرا کن تا رکورد قالب برای تگ جدید ساخته شود (فقط تگ‌های بدون رکورد را می‌سازد): +```bash +ddev exec php bin/console app:seed-sms-message-templates +``` + +### ۵. مستندسازی + +`docs/api/sms.md`: تگ `doctor_appointment`، قالب پیش‌فرض، placeholderها (`patient`/`date`/`time`)، و اینکه فقط برای نوبت‌های **پرداخت‌شدهٔ سایت** (در مسیر verify پرداخت) ارسال می‌شود و به `Doctor.notificationMobile` می‌رود. + +## نکات مهم + +- **فقط مسیر پرداخت:** کد داخل `handleAppointmentConfirmation` است که تنها از `runPostAction` بعد از verify موفق صدا زده می‌شود؛ نوبت‌های منشی (بدون پرداخت) این‌جا نمی‌آیند — پس شرط «نه نوبت منشی» خودکار برقرار است. کد را جای دیگری (مثل ساخت نوبت) نگذار. +- اگر `notificationMobile` خالی بود، هیچ پیامکی نرود (شرط `if ($notify)`). +- `dispatchAsync` مثل پیامک بیمار استفاده شود (صف async، شکست پیامک نباید جریان پرداخت را بشکند — الگوی موجود). +- ارسال پیامک دکتر **مستقل از** پیامک بیمار است (حتی اگر `patientMobile` خالی باشد، پیامک دکتر باید برود). +- `title`/`body` قالب فارسی و قابل ویرایش از پنل قالب‌هاست؛ فقط مقدار پیش‌فرض را در `DEFAULTS` بگذار. +- `Doctor` تغییر Entity ندارد (فیلد موجود است) → migration لازم نیست. +- هزینهٔ پیامک: این پیامک هم مثل بقیه از کیف‌پول/سهمیهٔ پیامک همان entity کسر می‌شود (اگر `SmsService`/wallet چنین منطقی دارد، خودکار اعمال می‌شود؛ چیزی اضافه نکن مگر لازم شود). diff --git a/docs/api/sms.md b/docs/api/sms.md index 2e2f2853..60eeb74f 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -482,11 +482,13 @@ Updated template with `status: "rejected"`. متن پیامک‌های سیستمی (OTP، پرداخت، دعوت کلینیک، پیش‌ثبت‌نام، تأیید موبایل) از پنل قابل ویرایش است و بر اساس **تگ** کلیددار می‌شود. هر متن placeholderهای مجاز خود را دارد (مثل `{code}`، `{doctor}`، `{date}`). هنگام ارسال، `SmsTextResolver` متنِ ویرایش‌شده‌ی DB را می‌گیرد و placeholderها را جایگزین می‌کند؛ اگر رکوردی نبود به متن پیش‌فرض fallback می‌شود. -> تگ‌ها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`، `welcome`، `secretary`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت می‌شود.) +> تگ‌ها: `otp`، `payment`، `clinic_invitation`، `pre_registration`، `notification_mobile`، `welcome`، `secretary`، `doctor_appointment`. (پیامک قالبیِ کاربر با تگ `user_template` جداگانه از طریق `POST /api/v1/sms/template` مدیریت می‌شود.) > > تگ `welcome`: پیامک خوش‌آمد که هنگام افزودن پزشک/کلینیک توسط نماینده (`POST /api/v1/representation/doctor|clinic`) به‌صورت async به موبایل پزشک/مالک ارسال می‌شود. متن فعلاً ثابت است (نام + `site_name`)، نه از قالب DB. > > تگ `secretary`: پیامک خوش‌آمد که هنگام تعریف منشی جدید (`POST /api/v1/secretary`) به‌صورت async به موبایل منشی ارسال می‌شود. placeholderها: `{owner}` (نام دکتر یا کلینیک)، `{username}` (موبایل منشی)، `{link}` (لینک ورود). متن از قالب DB می‌آید (fallback به پیش‌فرض `SmsMessageTemplate::DEFAULTS`). +> +> تگ `doctor_appointment`: اعلانِ «نوبت جدید» به **شمارهٔ اعلان دکتر** (`Doctor.notificationMobile` که در `/admin/profile` ست می‌شود). **فقط برای نوبت‌های پرداخت‌شدهٔ سایت** ارسال می‌شود — در `PaymentManager::handleAppointmentConfirmation` که تنها پس از verify موفقِ پرداخت اجرا می‌شود؛ نوبت‌های ثبت‌شده توسط منشی (بدون پرداخت) این پیامک را نمی‌گیرند. placeholderها: `{patient}` (نام بیمار)، `{date}` (تاریخ شمسی)، `{time}` (ساعت `HH:MM`). اگر `notificationMobile` خالی باشد ارسال نمی‌شود. متن از قالب DB (fallback به `DEFAULTS`). ### GET `/api/v1/admin/sms/messages` diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 84c6495c..7c708873 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -321,6 +321,17 @@ final class PaymentManager ]); $this->smsService->dispatchAsync($mobile, $message, tag: SmsLog::TAG_PAYMENT); } + + // اعلان به شمارهٔ «اعلان نوبت» دکتر — فقط برای نوبت‌های پرداخت‌شدهٔ سایت (همین مسیر). + $notify = $doctor->getNotificationMobile(); + if ($notify) { + $docMessage = $this->smsText->resolve(SmsLog::TAG_DOCTOR_APPOINTMENT, [ + 'patient' => $appointment->getPatientName() ?? '—', + 'date' => $this->jalali->formatDateTime($appointment->getSlotStart(), false), + 'time' => date('H:i', $appointment->getSlotStart()), + ]); + $this->smsService->dispatchAsync($notify, $docMessage, tag: SmsLog::TAG_DOCTOR_APPOINTMENT); + } } private function handleSubscriptionActivation(Payment $payment): void diff --git a/src/Sms/Entity/SmsLog.php b/src/Sms/Entity/SmsLog.php index 6c298cde..56aeccde 100644 --- a/src/Sms/Entity/SmsLog.php +++ b/src/Sms/Entity/SmsLog.php @@ -20,6 +20,7 @@ class SmsLog public const TAG_USER_TEMPLATE = 'user_template'; public const TAG_WELCOME = 'welcome'; public const TAG_SECRETARY = 'secretary'; + public const TAG_DOCTOR_APPOINTMENT = 'doctor_appointment'; public const TAGS = [ self::TAG_GLOBAL, @@ -31,6 +32,7 @@ class SmsLog self::TAG_USER_TEMPLATE, self::TAG_WELCOME, self::TAG_SECRETARY, + self::TAG_DOCTOR_APPOINTMENT, ]; #[ORM\Id] diff --git a/src/Sms/Entity/SmsMessageTemplate.php b/src/Sms/Entity/SmsMessageTemplate.php index c8ebda5b..1bb33a6c 100644 --- a/src/Sms/Entity/SmsMessageTemplate.php +++ b/src/Sms/Entity/SmsMessageTemplate.php @@ -48,6 +48,11 @@ class SmsMessageTemplate 'body' => "شما به‌عنوان منشیِ {owner} در کلینیک‌پرو تعریف شدید.\nشماره‌کاربری: {username}\nلینک ورود: {link}", 'variables' => ['owner', 'username', 'link'], ], + SmsLog::TAG_DOCTOR_APPOINTMENT => [ + 'title' => 'نوبت جدید (اعلان به پزشک)', + 'body' => "نوبت جدید ثبت شد.\nبیمار: {patient}\nتاریخ: {date} ساعت {time}\n\nبرای مدیریت نوبت‌های خود به‌صورت رایگان به سایت clinic-pro.ir مراجعه کنید.\nکلینیک پرو", + 'variables' => ['patient', 'date', 'time'], + ], ]; #[ORM\Id]