feat: add clinic management and financial reporting features

- Implemented ClinicFormPage for adding new clinics with validation.
- Created MyFinancialPage to display financial summaries and charts.
- Developed MyPatientsPage for managing patient data with search and pagination.
- Added PreRegistrationsPage for handling pre-registration requests with approval and rejection functionalities.
- Introduced database migration for pre_registrations table.
- Built PreRegistrationController for managing pre-registration logic, including submission, approval, and rejection.
- Created PreRegistration entity and repository for handling pre-registration data.
This commit is contained in:
hamed
2026-06-12 12:31:27 +03:30
parent 92cb834c22
commit 8ad983310c
14 changed files with 1772 additions and 469 deletions
@@ -0,0 +1,188 @@
<?php
namespace App\Auth\Controller;
use App\Auth\Entity\PreRegistration;
use App\Auth\Entity\User;
use App\Auth\Repository\PreRegistrationRepository;
use App\Auth\Repository\UserRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class PreRegistrationController extends BaseController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PreRegistrationRepository $preRegRepo,
private readonly UserRepository $userRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly SmsService $sms,
private readonly LoggerInterface $logger,
) {}
#[Route('/api/v1/pre-registration', methods: ['POST'])]
public function submit(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$type = trim($data['type'] ?? '');
$name = trim($data['name'] ?? '');
$mobile = trim($data['mobile'] ?? '');
$info = trim($data['info'] ?? '') ?: null;
$validTypes = [
PreRegistration::TYPE_INDEPENDENT_DOCTOR,
PreRegistration::TYPE_DOCTOR_WITH_CLINIC,
PreRegistration::TYPE_CLINIC_MANAGER,
];
if (!in_array($type, $validTypes, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع حساب معتبر نیست', 422);
}
if (mb_strlen($mobile) < 10 || mb_strlen($mobile) > 15) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل معتبر نیست', 422);
}
if (mb_strlen($name) < 2) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام الزامی است', 422);
}
if ($this->preRegRepo->hasPendingForMobile($mobile)) {
return $this->error('DUPLICATE_REQUEST', 'درخواست ثبت‌نام شما در حال بررسی است', 409);
}
$preReg = new PreRegistration($type, $name, $mobile, $info);
$this->em->persist($preReg);
$this->em->flush();
return $this->success(['uuid' => $preReg->getUuid(), 'status' => $preReg->getStatus()]);
}
#[Route('/api/v1/admin/pre-registrations', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function list(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
$status = $request->query->get('status', 'pending');
$qb = $this->em->createQueryBuilder()
->select('p.uuid, p.type, p.name, p.mobile, p.info, p.status, p.adminNote AS admin_note, p.createdAt AS created_at')
->from(PreRegistration::class, 'p');
if ($status !== 'all') {
$qb->where('p.status = :status')->setParameter('status', $status);
}
$total = (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
$items = $qb
->orderBy('p.createdAt', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getArrayResult();
return $this->paginated($items, (int) $total, $page, $limit);
}
#[Route('/api/v1/admin/pre-registrations/{uuid}/approve', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function approve(string $uuid): JsonResponse
{
$preReg = $this->preRegRepo->findOneBy(['uuid' => $uuid]);
if (!$preReg) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
}
if (!$preReg->isPending()) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این درخواست قبلاً پردازش شده است', 409);
}
$password = bin2hex(random_bytes(4));
$user = $this->userRepo->findOneBy(['mobileNumber' => $preReg->getMobile()]);
if (!$user) {
$user = new User($preReg->getMobile());
}
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
$user->setRealName($preReg->getName());
$this->em->persist($user);
$type = $preReg->getType();
$doctor = null;
if ($type === PreRegistration::TYPE_INDEPENDENT_DOCTOR || $type === PreRegistration::TYPE_DOCTOR_WITH_CLINIC) {
$user->addRole('ROLE_DOCTOR');
$doctor = $this->doctorRepo->findOneBy(['user' => $user]);
if (!$doctor) {
$doctor = new Doctor($user, $preReg->getName());
$doctor->setMobileNumber($preReg->getMobile());
$this->em->persist($doctor);
}
}
if ($type === PreRegistration::TYPE_DOCTOR_WITH_CLINIC || $type === PreRegistration::TYPE_CLINIC_MANAGER) {
$user->addRole('ROLE_CLINIC');
if (!$this->clinicRepo->findOneBy(['user' => $user])) {
$clinic = new Clinic($user);
$clinic->setName($preReg->getName());
$clinic->setTelephone($preReg->getMobile());
$clinic->setNotificationMobile($preReg->getMobile());
if ($doctor !== null) {
$clinic->getDoctors()->add($doctor);
}
$this->em->persist($clinic);
}
}
$preReg->approve();
$this->em->flush();
try {
$this->sms->dispatchAsync(
$preReg->getMobile(),
sprintf(
'به کلینیک پرو خوش آمدید! شماره‌کاربری: %s | رمز عبور: %s | لینک ورود: https://clinic-pro.ddev.site/admin',
$preReg->getMobile(),
$password
)
);
} catch (\Throwable $e) {
$this->logger->warning('PreRegistration SMS failed', ['uuid' => $uuid, 'error' => $e->getMessage()]);
}
return $this->success(['message' => 'تأیید شد و اطلاعات ورود ارسال گردید']);
}
#[Route('/api/v1/admin/pre-registrations/{uuid}/reject', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function reject(string $uuid, Request $request): JsonResponse
{
$preReg = $this->preRegRepo->findOneBy(['uuid' => $uuid]);
if (!$preReg) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404);
}
if (!$preReg->isPending()) {
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این درخواست قبلاً پردازش شده است', 409);
}
$data = json_decode($request->getContent(), true) ?? [];
$preReg->reject($data['note'] ?? null);
$this->em->flush();
return $this->success(['message' => 'درخواست رد شد']);
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Auth\Entity;
use App\Auth\Repository\PreRegistrationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: PreRegistrationRepository::class)]
#[ORM\Table(name: 'pre_registrations')]
#[ORM\Index(columns: ['mobile'], name: 'idx_prereg_mobile')]
#[ORM\Index(columns: ['status'], name: 'idx_prereg_status')]
class PreRegistration
{
public const TYPE_INDEPENDENT_DOCTOR = 'independent_doctor';
public const TYPE_DOCTOR_WITH_CLINIC = 'doctor_with_clinic';
public const TYPE_CLINIC_MANAGER = 'clinic_manager';
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 30)]
private string $type;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 20)]
private string $mobile;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $info = null;
#[ORM\Column(type: 'string', length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column(name: 'admin_note', type: 'text', nullable: true)]
private ?string $adminNote = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $type, string $name, string $mobile, ?string $info = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->type = $type;
$this->name = $name;
$this->mobile = $mobile;
$this->info = $info;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getType(): string { return $this->type; }
public function getName(): string { return $this->name; }
public function getMobile(): string { return $this->mobile; }
public function getInfo(): ?string { return $this->info; }
public function getStatus(): string { return $this->status; }
public function getAdminNote(): ?string { return $this->adminNote; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function approve(): void
{
$this->status = self::STATUS_APPROVED;
$this->updatedAt = time();
}
public function reject(?string $note = null): void
{
$this->status = self::STATUS_REJECTED;
$this->adminNote = $note;
$this->updatedAt = time();
}
public function isPending(): bool { return $this->status === self::STATUS_PENDING; }
}
@@ -0,0 +1,28 @@
<?php
namespace App\Auth\Repository;
use App\Auth\Entity\PreRegistration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PreRegistrationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PreRegistration::class);
}
public function hasPendingForMobile(string $mobile): bool
{
return (bool) $this->createQueryBuilder('p')
->select('1')
->where('p.mobile = :mobile')
->andWhere('p.status = :status')
->setParameter('mobile', $mobile)
->setParameter('status', PreRegistration::STATUS_PENDING)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}