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
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+14 -1
View File
@@ -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>
);
}
+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.
@@ -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
{
+12
View File
@@ -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
+8 -11
View File
@@ -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, [
+161
View File
@@ -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());