feat: add mobile number change functionality for doctors and clinics

- Implemented PATCH endpoints for changing the login mobile number of doctors and clinics.
- Added ChangeLoginMobileModal component for handling mobile number updates in the UI.
- Updated ClinicsPage and DoctorsPage to include buttons for changing mobile numbers.
- Enhanced AdminApiController to manage mobile number changes with validation.
- Created tests to ensure proper functionality and validation for mobile number changes.
- Updated API documentation to reflect new endpoints and their usage.
This commit is contained in:
hamed
2026-07-25 21:40:38 +03:30
parent 3cc4a59459
commit 50ba7e44ff
13 changed files with 520 additions and 38 deletions
+33
View File
@@ -945,6 +945,39 @@ Paginated secretary list with linked doctor info. هر ردیف علاوه بر
---
### PATCH `/api/v1/admin/doctors/{uuid}/mobile` · PATCH `/api/v1/admin/clinic/{uuid}/mobile`
تغییر شمارهٔ **ورود** حساب پزشک یا کلینیک توسط مدیر کل.
**Permission:** `ROLE_ADMIN`
#### Request Body (`application/json`)
```json
{ "mobile_number": "09123456789" }
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mobile_number` | string | ✅ | `09` + ۱۱ رقم؛ ارقام فارسی/عربی هم پذیرفته و نرمال‌سازی می‌شوند |
#### Response `200`
```json
{ "success": true, "data": { "data": { "mobile_number": "09123456789", "previous_mobile_number": "09120000000" } } }
```
- شماره **هویت ورود** کاربر است: بعد از تغییر، ورود فقط با شمارهٔ جدید ممکن است.
- برای پزشک، شمارهٔ نمایشیِ پروفایل (`Doctor.mobile_number`) هم اگر با شمارهٔ ورود یکی بوده (یا خالی است) با آن هم‌گام می‌شود تا دو مقدار واگرا نشوند.
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_DOCTOR_NOT_FOUND` / `ERR_NOT_FOUND_001` | 404 | پزشک/کلینیک یافت نشد |
| `ERR_CONFLICT_001` | 409 | این شماره قبلاً برای کاربر دیگری ثبت شده (`field: mobile_number`) |
| `ERR_VALIDATION_001` | 422 | قالب شماره نامعتبر (`field: mobile_number`) |
---
### GET `/api/v1/admin/secretary/{uuid}`
جزئیات یک **رابطهٔ** منشی–پزشک/کلینیک (`uuid` = `DoctorSecretary.uuid`، همان uuid لیست بالا) به‌همراه تنظیمات سهم و خلاصهٔ درآمد.
+5
View File
@@ -570,6 +570,11 @@ The sum of `payments` may not exceed the visit's payable amount → `ERR_SESSION
Partial payment is normal: the remainder stays as `remaining_rials` on the visit and can be
collected later through `POST /api/v1/session/{uuid}/payments`.
> **نوبت آنلاین با پرداخت موفق، خودبه‌خود قطعی نمی‌شود.** پرداخت فقط پنجرهٔ انقضای درگاه
> را برمی‌دارد (`expires_at = null`) و نوبت در وضعیت `pending` («ثبت شده») می‌ماند تا پزشک/منشی
> از همین اندپوینت آن را قطعی کند. ساخت پرونده/مراجعه و تقسیم مالی (پورسانت نماینده و سهم منشی)
> هم در همین لحظهٔ تأیید انجام می‌شود، نه لحظهٔ پرداخت.
### What happens on the server
1. انتخاب بیمه (اگر در بدنه آمده باشد) روی نوبت می‌نشیند و اعتبارسنجی می‌شود.
2. `pending → confirmed` (state machine still applies).
+5 -5
View File
@@ -32,7 +32,7 @@
[PaymentController::callback] (عمومی؛ ملت/سپ ریدایرکت مرورگر → بدون IP-check، امنیت با tamper+verify)
│ gateway->verify()؛ بررسی مبلغ؛ جلوگیری از replay (reference_id یکتا)؛ ست وضعیت
│ post-action: confirm نوبت / فعال‌سازی اشتراک / شارژ کیف‌پول + کمیسیون + پیامک
│ post-action: برداشتن انقضای نوبت / فعال‌سازی اشتراک / شارژ کیف‌پول + پیامک
│ (۴) RedirectResponse → frontend_address?payment_uuid=..&status=.. (همان دامنهٔ مبدأ)
[Frontend] /payment/result → نمایش وضعیت
@@ -201,9 +201,9 @@ Initiate payment for an appointment. Returns a redirect URL to the payment gatew
>
> **مبلغ نوبت:** مبلغِ پرداخت از کلید `appointment_fee_rials` تنظیمات سایت خوانده می‌شود (نه از client و نه hardcode). برای تغییر، در `/admin/settings` ویرایش کنید.
>
> **On successful callback** for an appointment payment, the booking is transitioned `pending → confirmed` (its 15-minute `expires_at` is cleared) and a confirmation SMS is dispatched to the patient's mobile. تاریخِ نوبت در متن پیامک به‌صورت **شمسی** (`JalaliDateService::formatDateTime`، مثل `۱۴۰۵/۰۴/۰۲ ۰۹:۰۰`) درج می‌شود. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
> **On successful callback** for an appointment payment, the booking **stays `pending`** («ثبت شده») and only its 15-minute `expires_at` is cleared, so a paid booking is never auto-expired. قطعی‌شدن تصمیم پزشک/منشی است: `POST /api/v1/appointment/{uuid}/confirm` ([appointment.md](appointment.md#post-apiv1appointmentuuidconfirm)). پیامک تأیید پرداخت همان لحظه برای بیمار ارسال می‌شود. تاریخِ نوبت در متن پیامک به‌صورت **شمسی** (`JalaliDateService::formatDateTime`، مثل `۱۴۰۵/۰۴/۰۲ ۰۹:۰۰`) درج می‌شود. If the booking already lapsed to `expired` before payment confirmed, it is **not** re-confirmed (the transition is rejected) — handle refund out of band.
>
> **پورسانت نماینده:** اگر پزشک نوبت `representation_id` داشته باشد و `appointment_commission_enabled=1` باشد، پس از confirm شدن `CommissionService` هزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیف‌پولش واریز می‌کند (ردیف `FinancialBreakdown` ثبت می‌شود). برای پرداخت اشتراک هم اگر `upgrade_commission_enabled=1` و پزشک/کلینیک `representation_id` داشته باشد همین منطق با درصد `upgrade_commission_percent` اعمال می‌شود. کلیدهای تنظیمات و ترتیب محاسبه در `docs/api/admin.md`.
> **پورسانت نماینده:** تقسیم مالی نوبت در لحظهٔ **تأیید نوبت** اجرا می‌شود (نه لحظهٔ پرداخت). اگر پزشک نوبت `representation_id` داشته باشد و `appointment_commission_enabled=1` باشد، `CommissionService` هزینه پنل پیامک و مالیات را کسر و سهم نماینده را به کیف‌پولش واریز می‌کند (ردیف `FinancialBreakdown` ثبت می‌شود). برای پرداخت اشتراک هم اگر `upgrade_commission_enabled=1` و پزشک/کلینیک `representation_id` داشته باشد همین منطق با درصد `upgrade_commission_percent` اعمال می‌شود. کلیدهای تنظیمات و ترتیب محاسبه در `docs/api/admin.md`.
### Errors
| Code | HTTP | Description |
@@ -300,7 +300,7 @@ After verifying the gateway result, the backend redirects the user **back to the
```
{frontend_address}?payment_uuid={uuid}&status={status}
```
- **Success** (`verify` ok **and** amount matches): payment → `success`, then the type-specific action runs (appointment → `confirmed`, subscription → activated, sms_wallet → credited).
- **Success** (`verify` ok **and** amount matches): payment → `success`, then the type-specific action runs (appointment → پنجرهٔ انقضا پاک می‌شود ولی وضعیت `pending` می‌ماند، subscription → activated, sms_wallet → credited).
- **Amount mismatch**: when the gateway reports the settled amount (SEP `AffectiveAmount`) and it does **not** equal the order's `amount_rials`, the callback is treated as failed — payment → `failed`, the type-specific action does **not** run. Guards against underpayment and replaying another (cheaper) order's reference. Gateways that don't report a settled amount (Mellat binds it server-side to the original request) skip this check.
- **Replayed reference**: a gateway `reference_id` identifies exactly one settled transaction. If the callback's reference already belongs to another payment, it is rejected (payment → `failed`). Enforced by a unique index on `payments.reference_id` with an application-level pre-check.
- **User canceled** (e.g. Mellat `ResCode=17`, SEP `State=CanceledByUser`, mock `cancel=1`): payment → `canceled`. The gateway circuit-breaker is **not** marked as failed (it's a user choice, not a gateway fault).
@@ -309,7 +309,7 @@ After verifying the gateway result, the backend redirects the user **back to the
If `frontend_address` is empty, a JSON body `{ success, payment }` is returned instead of a redirect.
### Notes
- On appointment success: status `confirmed`, its 15-minute `expires_at` cleared, confirmation SMS dispatched.
- On appointment success: status می‌ماند `pending`، فقط `expires_at` پاک می‌شود و پیامک پرداخت ارسال می‌گردد؛ قطعی‌کردن با `POST /api/v1/appointment/{uuid}/confirm` است.
- Payment record stores: `order_id`, `amount_rials`, `status`, `gateway`, `reference_id`, `frontend_address`, `callback_ip`.
- Same flow for **all clients** (the main site and every consumer site) — the only per-client difference is `frontend_address`, which is validated against an allowlist (see below) to prevent open redirects.