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
@@ -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, [