From 50ba7e44ff26d43cb9c33261dd5ebb52e79157ef Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 25 Jul 2026 21:40:38 +0330 Subject: [PATCH] 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. --- .../components/ChangeLoginMobileModal.tsx | 81 +++++++++ assets/admin/pages/ClinicsPage.tsx | 17 ++ assets/admin/pages/DoctorsPage.tsx | 15 +- docs/api/admin.md | 33 ++++ docs/api/appointment.md | 5 + docs/api/payment.md | 10 +- src/Admin/Controller/AdminApiController.php | 80 +++++++++ src/Appointment/Entity/Appointment.php | 12 ++ .../AppointmentConfirmationService.php | 31 ++++ src/Payment/Repository/PaymentRepository.php | 9 + src/Payment/Service/PaymentManager.php | 19 +-- tests/Admin/AdminChangeLoginMobileTest.php | 161 ++++++++++++++++++ ...AppointmentPaidConfirmFilesSessionTest.php | 85 ++++++--- 13 files changed, 520 insertions(+), 38 deletions(-) create mode 100644 assets/admin/components/ChangeLoginMobileModal.tsx create mode 100644 tests/Admin/AdminChangeLoginMobileTest.php diff --git a/assets/admin/components/ChangeLoginMobileModal.tsx b/assets/admin/components/ChangeLoginMobileModal.tsx new file mode 100644 index 00000000..6b2587ca --- /dev/null +++ b/assets/admin/components/ChangeLoginMobileModal.tsx @@ -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 ( + + لغو + save.mutate()} + disabled={save.isPending || invalid} + className="btn primary sm" + > + {save.isPending ? 'در حال ذخیره...' : 'ذخیره'} + + > + } + > + + این شماره، شمارهٔ ورود به پنل است. پس از تغییر، ورود فقط با شمارهٔ جدید ممکن + خواهد بود و شماره باید در کل سامانه یکتا باشد. + + + + شماره موبایل + setMobile(digitsOnly(e.target.value, 11))} + /> + {mobile !== '' && invalid && شماره باید با ۰۹ شروع شود و ۱۱ رقم باشد} + + + ); +} diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 76dd11a8..192379bc 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -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(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() { > + setMobileTarget({ uuid: c.uuid, name: c.name, mobile_number: c.owner_mobile })} + > + + deleteTarget && deleteMutation.mutate(deleteTarget)} onCancel={() => setDeleteTarget(null)} /> + + setMobileTarget(null)} + /> ); } diff --git a/assets/admin/pages/DoctorsPage.tsx b/assets/admin/pages/DoctorsPage.tsx index bfb1c836..a2a9204d 100644 --- a/assets/admin/pages/DoctorsPage.tsx +++ b/assets/admin/pages/DoctorsPage.tsx @@ -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(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}`)}> + setMobileTarget({ uuid: doc.uuid, name: doc.name, mobile_number: doc.mobile })}> + + toggleMut.mutate(doc.uuid)} disabled={toggleMut.isPending}> {doc.is_active @@ -517,6 +523,13 @@ export default function DoctorsPage() { onCancel={() => setDeleteTarget(null)} /> + setMobileTarget(null)} + /> + ); } diff --git a/docs/api/admin.md b/docs/api/admin.md index 1a512f77..22b76382 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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 لیست بالا) بههمراه تنظیمات سهم و خلاصهٔ درآمد. diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 0dd01f30..ca5aa789 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -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). diff --git a/docs/api/payment.md b/docs/api/payment.md index edf43eb7..fddc8456 100644 --- a/docs/api/payment.md +++ b/docs/api/payment.md @@ -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. diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index d7609add..858230b2 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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 { diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php index 47f93fb8..d4573de1 100644 --- a/src/Appointment/Entity/Appointment.php +++ b/src/Appointment/Entity/Appointment.php @@ -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 { diff --git a/src/Appointment/Service/AppointmentConfirmationService.php b/src/Appointment/Service/AppointmentConfirmationService.php index e84c8068..9d4cf9df 100644 --- a/src/Appointment/Service/AppointmentConfirmationService.php +++ b/src/Appointment/Service/AppointmentConfirmationService.php @@ -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) { diff --git a/src/Payment/Repository/PaymentRepository.php b/src/Payment/Repository/PaymentRepository.php index e526dd7b..c6d4ea03 100644 --- a/src/Payment/Repository/PaymentRepository.php +++ b/src/Payment/Repository/PaymentRepository.php @@ -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 diff --git a/src/Payment/Service/PaymentManager.php b/src/Payment/Service/PaymentManager.php index 09b09087..f941ad7e 100644 --- a/src/Payment/Service/PaymentManager.php +++ b/src/Payment/Service/PaymentManager.php @@ -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, [ diff --git a/tests/Admin/AdminChangeLoginMobileTest.php b/tests/Admin/AdminChangeLoginMobileTest.php new file mode 100644 index 00000000..52ac3980 --- /dev/null +++ b/tests/Admin/AdminChangeLoginMobileTest.php @@ -0,0 +1,161 @@ +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()); + } +} diff --git a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php index b8cd620e..c3bb64c4 100644 --- a/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php +++ b/tests/Payment/AppointmentPaidConfirmFilesSessionTest.php @@ -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());
+ این شماره، شمارهٔ ورود به پنل است. پس از تغییر، ورود فقط با شمارهٔ جدید ممکن + خواهد بود و شماره باید در کل سامانه یکتا باشد. +
شماره باید با ۰۹ شروع شود و ۱۱ رقم باشد