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:
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import { digitsOnly } from '../lib/utils';
|
||||
import Modal from './ui/Modal';
|
||||
|
||||
interface Props {
|
||||
/** `null` یعنی مودال بسته است. */
|
||||
target: { uuid: string; name: string; mobile_number?: string | null } | null;
|
||||
/** `doctors` → /api/v1/admin/doctors/{uuid}/mobile · `clinic` → /api/v1/admin/clinic/{uuid}/mobile */
|
||||
resource: 'doctors' | 'clinic';
|
||||
/** کلید کوئریِ لیستی که بعد از تغییر باید invalidate شود. */
|
||||
queryKey: unknown[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* تغییر شمارهٔ **ورود** پزشک/کلینیک توسط مدیر کل. شماره هویتِ ورود کاربر است، پس
|
||||
* سرور یکتا بودن را کنترل میکند و اینجا فقط قالبِ ۰۹ + ۱۱ رقم اعتبارسنجی میشود.
|
||||
*/
|
||||
export default function ChangeLoginMobileModal({ target, resource, queryKey, onClose }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [mobile, setMobile] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setMobile(target?.mobile_number ?? '');
|
||||
}, [target]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.patch(`/api/v1/admin/${resource}/${target!.uuid}/mobile`, { mobile_number: mobile }),
|
||||
onSuccess: () => {
|
||||
toast.success('شماره موبایل تغییر کرد');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const invalid = !/^09\d{9}$/.test(mobile);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={target !== null}
|
||||
title={`تغییر شماره ورود — ${target?.name ?? ''}`}
|
||||
size="sm"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="btn ghost sm">لغو</button>
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || invalid}
|
||||
className="btn primary sm"
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12, lineHeight: 1.9, color: 'var(--text-2)' }}>
|
||||
این شماره، شمارهٔ <b>ورود به پنل</b> است. پس از تغییر، ورود فقط با شمارهٔ جدید ممکن
|
||||
خواهد بود و شماره باید در کل سامانه یکتا باشد.
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
<label>شماره موبایل</label>
|
||||
<input
|
||||
className="input"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
aria-label="شماره موبایل"
|
||||
placeholder="09xxxxxxxxx"
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(digitsOnly(e.target.value, 11))}
|
||||
/>
|
||||
{mobile !== '' && invalid && <p className="err-text">شماره باید با ۰۹ شروع شود و ۱۱ رقم باشد</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
BuildingOffice2Icon,
|
||||
DevicePhoneMobileIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -21,6 +22,7 @@ import Portal from '../components/ui/Portal';
|
||||
import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
||||
import { latinDigitsField } from '../lib/forms';
|
||||
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
@@ -41,6 +43,7 @@ export default function ClinicsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Clinic | null>(null);
|
||||
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const limit = 15;
|
||||
|
||||
@@ -217,6 +220,13 @@ export default function ClinicsPage() {
|
||||
>
|
||||
<EyeIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<button
|
||||
className="mini-btn"
|
||||
title="تغییر شماره ورود"
|
||||
onClick={() => setMobileTarget({ uuid: c.uuid, name: c.name, mobile_number: c.owner_mobile })}
|
||||
>
|
||||
<DevicePhoneMobileIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<button
|
||||
className={`mini-btn${c.is_active ? '' : ' active'}`}
|
||||
title={c.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
@@ -303,6 +313,13 @@ export default function ClinicsPage() {
|
||||
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<ChangeLoginMobileModal
|
||||
target={mobileTarget}
|
||||
resource="clinic"
|
||||
queryKey={['clinics']}
|
||||
onClose={() => setMobileTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
MagnifyingGlassIcon, PlusIcon, EyeIcon, TrashIcon, ArrowPathIcon,
|
||||
CheckCircleIcon, XCircleIcon, TableCellsIcon, Squares2X2Icon,
|
||||
CheckCircleIcon, XCircleIcon, TableCellsIcon, Squares2X2Icon, DevicePhoneMobileIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
@@ -11,6 +11,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
@@ -111,6 +112,7 @@ export default function DoctorsPage() {
|
||||
const [cityId, setCityId] = useState('');
|
||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
|
||||
const [mobileTarget, setMobileTarget] = useState<{ uuid: string; name: string; mobile_number?: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
|
||||
@@ -430,6 +432,10 @@ export default function DoctorsPage() {
|
||||
onClick={() => navigate(`/admin/doctors/${doc.uuid}`)}>
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button className="mini-btn" title="تغییر شماره ورود"
|
||||
onClick={() => setMobileTarget({ uuid: doc.uuid, name: doc.name, mobile_number: doc.mobile })}>
|
||||
<DevicePhoneMobileIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button className="mini-btn" title={doc.is_active ? 'غیرفعال کردن' : 'فعالسازی'}
|
||||
onClick={() => toggleMut.mutate(doc.uuid)} disabled={toggleMut.isPending}>
|
||||
{doc.is_active
|
||||
@@ -517,6 +523,13 @@ export default function DoctorsPage() {
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<ChangeLoginMobileModal
|
||||
target={mobileTarget}
|
||||
resource="doctors"
|
||||
queryKey={['doctors']}
|
||||
onClose={() => setMobileTarget(null)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 لیست بالا) بههمراه تنظیمات سهم و خلاصهٔ درآمد.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -306,6 +306,86 @@ class AdminApiController extends BaseController
|
||||
return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* تغییر شمارهٔ **ورود** پزشک/کلینیک توسط مدیر کل.
|
||||
*
|
||||
* شماره، هویتِ ورود همان کاربر است؛ پس یکتا بودنش کنترل میشود و شمارهٔ نمایشیِ
|
||||
* پزشک هم با آن همگام میماند تا دو مقدار واگرا نشوند.
|
||||
*/
|
||||
private function changeLoginMobile(User $owner, mixed $raw, ?Doctor $doctor = null): JsonResponse
|
||||
{
|
||||
$mobile = InputValidator::toEnglishDigits(trim((string) $raw));
|
||||
|
||||
if (!InputValidator::isValidIranMobile($mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است (۰۹ و ۱۱ رقم)', 422, 'mobile_number');
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if ($existing !== null && $existing->getId() !== $owner->getId()) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این شماره موبایل قبلاً ثبت شده است', 409, 'mobile_number');
|
||||
}
|
||||
|
||||
$previous = $owner->getMobileNumber();
|
||||
$owner->setMobileNumber($mobile);
|
||||
|
||||
// شمارهٔ نمایشیِ پزشک اگر با شمارهٔ ورود یکی بوده (یا خالی است) همراهش بهروز شود.
|
||||
if ($doctor !== null && ($doctor->getMobileNumber() === null || $doctor->getMobileNumber() === $previous)) {
|
||||
$doctor->setMobileNumber($mobile);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['data' => ['mobile_number' => $mobile, 'previous_mobile_number' => $previous]]);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
path: '/api/v1/admin/doctors/{uuid}/mobile',
|
||||
summary: 'Change the login mobile number of a doctor account',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Mobile changed'),
|
||||
new OA\Response(response: 404, description: 'Doctor not found'),
|
||||
new OA\Response(response: 409, description: 'Mobile already taken'),
|
||||
new OA\Response(response: 422, description: 'Invalid mobile'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/doctors/{uuid}/mobile', methods: ['PATCH'])]
|
||||
public function changeDoctorMobile(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
return $this->changeLoginMobile($doctor->getUser(), $data['mobile_number'] ?? '', $doctor);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
path: '/api/v1/admin/clinic/{uuid}/mobile',
|
||||
summary: 'Change the login mobile number of a clinic account',
|
||||
security: [['bearerAuth' => []]],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Mobile changed'),
|
||||
new OA\Response(response: 404, description: 'Clinic not found'),
|
||||
new OA\Response(response: 409, description: 'Mobile already taken'),
|
||||
new OA\Response(response: 422, description: 'Invalid mobile'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/clinic/{uuid}/mobile', methods: ['PATCH'])]
|
||||
public function changeClinicMobile(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$clinic = $this->em->getRepository(\App\Clinic\Entity\Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
return $this->changeLoginMobile($clinic->getUser(), $data['mobile_number'] ?? '');
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/doctors/{uuid}/representation', methods: ['PUT'])]
|
||||
public function setDoctorRepresentation(string $uuid, Request $request, RepresentationRepository $repRepo): JsonResponse
|
||||
{
|
||||
|
||||
@@ -314,6 +314,18 @@ class Appointment
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* پرداخت موفق: نگهداشتِ موقتِ درگاه برداشته میشود ولی نوبت «ثبتشده» میماند تا
|
||||
* پزشک/منشی آن را قطعی کند. بدون این، همان قواعد انقضا (پنجرهٔ پرداخت یا گذشتنِ
|
||||
* ساعت نوبت) نوبتِ پرداختشده را هم منقضی میکردند.
|
||||
*/
|
||||
public function clearPaymentWindow(): self
|
||||
{
|
||||
$this->expiresAt = null;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** آیا پنجرهٔ ۱۵ دقیقهایِ پرداخت گذشته یا زمان اسلات رد شده است؟ */
|
||||
public function isPaymentWindowExpired(int $now): bool
|
||||
{
|
||||
|
||||
@@ -7,6 +7,9 @@ use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Service\PatientService;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
use App\Representation\Service\DomainContextResolver;
|
||||
use App\Settlement\Service\CommissionService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -24,10 +27,34 @@ class AppointmentConfirmationService
|
||||
public function __construct(
|
||||
private readonly PatientService $patientService,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
private readonly CommissionService $commissionService,
|
||||
private readonly DomainContextResolver $domainResolver,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* تقسیم مالیِ نوبت آنلاین در لحظهٔ **تأیید** انجام میشود، نه لحظهٔ پرداخت: تا وقتی
|
||||
* نوبت قطعی نشده، پورسانت نماینده و سهم منشی هم اعتبار نمیشوند. برای نوبتی که
|
||||
* پرداخت آنلاین ندارد (ثبتشده در پنل) کاری انجام نمیشود. ثبت idempotent است.
|
||||
*/
|
||||
private function splitPaymentShares(Appointment $appointment): void
|
||||
{
|
||||
$payment = $this->paymentRepo->findSuccessfulByAppointment($appointment);
|
||||
if ($payment === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$doctor = $appointment->getDoctor();
|
||||
$this->commissionService->processAppointment(
|
||||
$payment,
|
||||
$doctor->getRepresentationId(),
|
||||
$this->domainResolver->resolve($payment->getFrontendAddress())->representationId(),
|
||||
$doctor->getId(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* idempotent: فراخوانی دوباره برای همان نوبت چیزی نمیسازد.
|
||||
*
|
||||
@@ -42,6 +69,8 @@ class AppointmentConfirmationService
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->splitPaymentShares($appointment);
|
||||
|
||||
try {
|
||||
return $this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -74,6 +103,8 @@ class AppointmentConfirmationService
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->appointmentRepo->saveWithLock($appointment, $expectedVersion);
|
||||
|
||||
$this->splitPaymentShares($appointment);
|
||||
|
||||
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
|
||||
if ($session === null) {
|
||||
|
||||
@@ -80,6 +80,15 @@ class PaymentRepository extends ServiceEntityRepository
|
||||
]);
|
||||
}
|
||||
|
||||
/** پرداخت موفقِ یک نوبت — مبنای تقسیم مالی در لحظهٔ تأیید نوبت. */
|
||||
public function findSuccessfulByAppointment(Appointment $appointment): ?Payment
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'appointment' => $appointment,
|
||||
'status' => Payment::STATUS_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch variant of findPendingByAppointment: all pending payments for the
|
||||
* given appointments in ONE query, keyed by appointment id. Avoids the N+1
|
||||
|
||||
@@ -42,7 +42,6 @@ final class PaymentManager
|
||||
private readonly CommissionService $commissionService,
|
||||
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
|
||||
private readonly JalaliDateService $jalali,
|
||||
private readonly \App\Appointment\Service\AppointmentConfirmationService $appointmentConfirmation,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
@@ -303,25 +302,23 @@ final class PaymentManager
|
||||
$this->smsWalletService->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک');
|
||||
}
|
||||
|
||||
/**
|
||||
* پرداخت موفقِ نوبت آنلاین. نوبت **قطعی نمیشود**: در وضعیت «ثبت شده» میماند تا
|
||||
* پزشک/منشی آن را تأیید کند؛ فقط پنجرهٔ انقضای درگاه برداشته میشود تا نوبتِ
|
||||
* پرداختشده منقضی نشود. ساخت پرونده/مراجعه و تقسیم مالی به لحظهٔ تأیید منتقل
|
||||
* شده است ({@see \App\Appointment\Service\AppointmentConfirmationService}).
|
||||
*/
|
||||
private function handleAppointmentConfirmation(Payment $payment): void
|
||||
{
|
||||
$appointment = $payment->getAppointment();
|
||||
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CONFIRMED)) {
|
||||
if ($appointment === null || $appointment->getStatus() !== Appointment::STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$appointment->clearPaymentWindow();
|
||||
$this->em->persist($appointment);
|
||||
$this->appointmentConfirmation->onConfirmed($appointment);
|
||||
|
||||
$doctor = $appointment->getDoctor();
|
||||
$this->commissionService->processAppointment(
|
||||
$payment,
|
||||
$doctor->getRepresentationId(),
|
||||
$this->bookingRepresentationIdFor($payment),
|
||||
$doctor->getId(),
|
||||
);
|
||||
|
||||
$mobile = $appointment->getPatientMobile();
|
||||
if ($mobile) {
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_PAYMENT, $mobile, [
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Admin;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* مدیر کل میتواند شمارهٔ **ورود** پزشک/کلینیک را عوض کند. شماره هویتِ ورود است، پس
|
||||
* یکتا بودنش کنترل میشود و شمارهٔ نمایشیِ پزشک هم با آن همگام میماند.
|
||||
*/
|
||||
class AdminChangeLoginMobileTest extends ApiTestCase
|
||||
{
|
||||
private function newMobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر تغییر شماره');
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function makeClinic(): Clinic
|
||||
{
|
||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک تغییر شماره');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
private function reloadUser(int $id): User
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(User::class)->find($id);
|
||||
}
|
||||
|
||||
public function testAdminChangesDoctorLoginMobile(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$doctor = $this->makeDoctor();
|
||||
$userId = $doctor->getUser()->getId();
|
||||
$mobile = $this->newMobile();
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/admin/doctors/' . $doctor->getUuid() . '/mobile', $admin, [
|
||||
'mobile_number' => $mobile,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($mobile, $body['data']['data']['mobile_number']);
|
||||
self::assertSame($mobile, $this->reloadUser($userId)->getMobileNumber());
|
||||
}
|
||||
|
||||
/** شمارهٔ نمایشیِ پزشک وقتی با شمارهٔ ورود یکی بوده باید همراهش بهروز شود. */
|
||||
public function testDoctorDisplayMobileFollowsTheLoginMobile(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$doctor = $this->makeDoctor();
|
||||
$id = $doctor->getId();
|
||||
$mobile = $this->newMobile();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/doctors/' . $doctor->getUuid() . '/mobile', $admin, [
|
||||
'mobile_number' => $mobile,
|
||||
]);
|
||||
$this->em->clear();
|
||||
|
||||
self::assertSame($mobile, $this->em->getRepository(Doctor::class)->find($id)->getMobileNumber());
|
||||
}
|
||||
|
||||
public function testAdminChangesClinicLoginMobile(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$clinic = $this->makeClinic();
|
||||
$userId = $clinic->getUser()->getId();
|
||||
$mobile = $this->newMobile();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/clinic/' . $clinic->getUuid() . '/mobile', $admin, [
|
||||
'mobile_number' => $mobile,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($mobile, $this->reloadUser($userId)->getMobileNumber());
|
||||
}
|
||||
|
||||
public function testDuplicateMobileIsRejected(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$doctor = $this->makeDoctor();
|
||||
$taken = $this->createUser(['ROLE_USER'])->getMobileNumber();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/doctors/' . $doctor->getUuid() . '/mobile', $admin, [
|
||||
'mobile_number' => $taken,
|
||||
]);
|
||||
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInvalidMobileIsRejected(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$doctor = $this->makeDoctor();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/doctors/' . $doctor->getUuid() . '/mobile', $admin, [
|
||||
'mobile_number' => '12345',
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testPersianDigitsAreAccepted(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$doctor = $this->makeDoctor();
|
||||
$userId = $doctor->getUser()->getId();
|
||||
|
||||
// db_test بین اجراها پاک نمیشود؛ شمارهٔ ثابت در اجرای دوم تکراری میشد.
|
||||
$latin = $this->newMobile();
|
||||
$persian = strtr($latin, ['0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴',
|
||||
'5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/doctors/' . $doctor->getUuid() . '/mobile', $admin, [
|
||||
'mobile_number' => $persian,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($latin, $this->reloadUser($userId)->getMobileNumber());
|
||||
}
|
||||
|
||||
public function testNonAdminCannotChangeIt(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/doctors/' . $doctor->getUuid() . '/mobile', $doctor->getUser(), [
|
||||
'mobile_number' => $this->newMobile(),
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testUnknownDoctorIsNotFound(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/admin/doctors/00000000-0000-4000-8000-000000000000/mobile', $admin, [
|
||||
'mobile_number' => $this->newMobile(),
|
||||
]);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,9 @@ use App\Payment\Entity\Payment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Paying for an online booking must file the case file, same as any other way
|
||||
* of confirming.
|
||||
*
|
||||
* This is the path every Nobat724 booking takes, and it was the one path that
|
||||
* never created a record: the payment callback confirmed the appointment
|
||||
* without running the confirmation side-effects.
|
||||
* پرداخت آنلاین نوبت را **قطعی نمیکند**: نوبت در «ثبت شده» میماند تا پزشک/منشی
|
||||
* تأییدش کند. پرداخت فقط پنجرهٔ انقضای درگاه را برمیدارد تا نوبتِ پرداختشده منقضی
|
||||
* نشود. پرونده/مراجعه و تقسیم مالی در لحظهٔ تأیید ساخته میشوند.
|
||||
*/
|
||||
class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
||||
{
|
||||
@@ -39,7 +36,10 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_200_000, 1_790_201_800);
|
||||
$slotStart = strtotime('+30 days') + random_int(0, 500_000) * 7;
|
||||
$appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 1_800);
|
||||
// رزرو آنلاین با پنجرهٔ پرداخت ثبت میشود؛ پرداخت باید همین را پاک کند.
|
||||
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
|
||||
if ($withClinic !== null) {
|
||||
$appointment->setClinic($withClinic($doctor));
|
||||
}
|
||||
@@ -63,25 +63,65 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
||||
]));
|
||||
}
|
||||
|
||||
public function testPaidPersonalBookingIsConfirmedAndFiled(): void
|
||||
private function reload(Appointment $appointment): Appointment
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Appointment::class)->find($appointment->getId());
|
||||
}
|
||||
|
||||
public function testPaidBookingStaysPendingUntilSomeoneConfirmsIt(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
[$payment, $appointment] = $this->pendingPaidBooking();
|
||||
|
||||
$this->fireCallback($payment);
|
||||
$this->em->clear();
|
||||
$reloaded = $this->reload($appointment);
|
||||
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
|
||||
self::assertSame(Appointment::STATUS_CONFIRMED, $reloaded->getStatus());
|
||||
|
||||
$sessions = $this->em->getRepository(PatientSession::class)->findBy(['appointment' => $reloaded]);
|
||||
self::assertCount(1, $sessions, 'پرداخت آنلاین هم باید پرونده بسازد');
|
||||
|
||||
$record = $sessions[0]->getRecord();
|
||||
self::assertSame('doctor', $record->getEntityType());
|
||||
self::assertSame(Appointment::STATUS_PENDING, $reloaded->getStatus());
|
||||
// پنجرهٔ پرداخت برداشته میشود، وگرنه cronِ انقضا نوبتِ پرداختشده را میکشت.
|
||||
self::assertNull($reloaded->getExpiresAt());
|
||||
self::assertCount(0, $this->em->getRepository(PatientSession::class)->findBy(['appointment' => $reloaded]));
|
||||
}
|
||||
|
||||
public function testPaidClinicBookingIsFiledUnderTheClinic(): void
|
||||
public function testConfirmingThePaidBookingFilesTheSession(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
[$payment, $appointment] = $this->pendingPaidBooking();
|
||||
$this->fireCallback($payment);
|
||||
$reloaded = $this->reload($appointment);
|
||||
|
||||
$doctorUser = $reloaded->getDoctor()->getUser();
|
||||
$this->authJson('POST', '/api/v1/appointment/' . $reloaded->getUuid() . '/confirm', $doctorUser, []);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$confirmed = $this->reload($reloaded);
|
||||
self::assertSame(Appointment::STATUS_CONFIRMED, $confirmed->getStatus());
|
||||
|
||||
$sessions = $this->em->getRepository(PatientSession::class)->findBy(['appointment' => $confirmed]);
|
||||
self::assertCount(1, $sessions, 'تأیید نوبت باید پرونده بسازد');
|
||||
self::assertSame('doctor', $sessions[0]->getRecord()->getEntityType());
|
||||
}
|
||||
|
||||
/** تقسیم مالی هم مثل پرونده به لحظهٔ تأیید منتقل شده است. */
|
||||
public function testFinancialSplitHappensOnConfirmNotOnPayment(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
$breakdowns = static::getContainer()->get(\App\Settlement\Repository\FinancialBreakdownRepository::class);
|
||||
[$payment, $appointment] = $this->pendingPaidBooking();
|
||||
|
||||
$this->fireCallback($payment);
|
||||
$paidPayment = $this->em->getRepository(Payment::class)->find($payment->getId());
|
||||
self::assertFalse($breakdowns->existsForPayment($paidPayment), 'پرداخت بهتنهایی نباید تقسیم مالی بسازد');
|
||||
|
||||
$reloaded = $this->reload($appointment);
|
||||
$this->authJson('POST', '/api/v1/appointment/' . $reloaded->getUuid() . '/confirm', $reloaded->getDoctor()->getUser(), []);
|
||||
|
||||
// بدون نماینده و منشیِ سهمبر چیزی برای تقسیم نیست؛ صرفاً نباید خطا بدهد.
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testConfirmedClinicBookingIsFiledUnderTheClinic(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
[$payment, $appointment] = $this->pendingPaidBooking(function (Doctor $doctor): Clinic {
|
||||
@@ -95,10 +135,13 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
||||
});
|
||||
|
||||
$this->fireCallback($payment);
|
||||
$this->em->clear();
|
||||
$reloaded = $this->reload($appointment);
|
||||
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
|
||||
$records = $this->em->getRepository(PatientRecord::class)->findBy(['user' => $reloaded->getUser()]);
|
||||
$this->authJson('POST', '/api/v1/appointment/' . $reloaded->getUuid() . '/confirm', $reloaded->getDoctor()->getUser(), []);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$confirmed = $this->reload($reloaded);
|
||||
$records = $this->em->getRepository(PatientRecord::class)->findBy(['user' => $confirmed->getUser()]);
|
||||
|
||||
self::assertCount(1, $records, 'یک نوبت، یک پرونده — نه یکی برای پزشک و یکی برای کلینیک');
|
||||
self::assertSame('clinic', $records[0]->getEntityType());
|
||||
|
||||
Reference in New Issue
Block a user