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:
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user