feat: implement staff management and subscription system
- Added StaffController for managing clinic staff, including listing, creating, updating, and toggling staff status. - Created ClinicStaff entity and repository for staff data handling. - Developed SubscriptionController to manage subscription plans and periods, including trial subscriptions. - Introduced SubscriptionPlan, SubscriptionPeriod, and ClinicSubscription entities for subscription management. - Implemented SubscriptionService for handling subscription logic, including trial activation and subscription creation from payments. - Added necessary repositories for subscription entities to facilitate data access and manipulation.
This commit is contained in:
@@ -1687,34 +1687,37 @@ class AdminApiController extends BaseController
|
||||
// ── Dashboard Charts ──────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/dashboard/charts', methods: ['GET'])]
|
||||
public function dashboardCharts(): JsonResponse
|
||||
public function dashboardCharts(Request $request): JsonResponse
|
||||
{
|
||||
$conn = $this->em->getConnection();
|
||||
$days = 30;
|
||||
$now = time();
|
||||
$start = $now - ($days * 86400);
|
||||
$conn = $this->em->getConnection();
|
||||
$now = time();
|
||||
|
||||
$from = $request->query->get('from') ? (int) $request->query->get('from') : $now - (30 * 86400);
|
||||
$to = $request->query->get('to') ? (int) $request->query->get('to') : $now;
|
||||
|
||||
$apptRows = $conn->fetchAllAssociative(
|
||||
'SELECT DATE(FROM_UNIXTIME(slot_start)) AS d, COUNT(*) AS cnt
|
||||
FROM appointments WHERE slot_start >= :s GROUP BY d ORDER BY d',
|
||||
['s' => $start]
|
||||
FROM appointments WHERE slot_start BETWEEN :s AND :e GROUP BY d ORDER BY d',
|
||||
['s' => $from, 'e' => $to]
|
||||
);
|
||||
$apptMap = array_column($apptRows, 'cnt', 'd');
|
||||
|
||||
$revRows = $conn->fetchAllAssociative(
|
||||
'SELECT DATE(FROM_UNIXTIME(created_at)) AS d, COALESCE(SUM(amount_rials), 0) AS total
|
||||
FROM payments WHERE status = :st AND created_at >= :s GROUP BY d ORDER BY d',
|
||||
['st' => 'received', 's' => $start]
|
||||
FROM payments WHERE status = :st AND created_at BETWEEN :s AND :e GROUP BY d ORDER BY d',
|
||||
['st' => 'received', 's' => $from, 'e' => $to]
|
||||
);
|
||||
$revMap = array_column($revRows, 'total', 'd');
|
||||
|
||||
$appt30d = [];
|
||||
$rev30d = [];
|
||||
for ($i = $days - 1; $i >= 0; $i--) {
|
||||
$date = date('Y-m-d', $now - $i * 86400);
|
||||
$shortDate = date('m/d', $now - $i * 86400);
|
||||
$appt30d[] = ['date' => $shortDate, 'count' => (int)($apptMap[$date] ?? 0)];
|
||||
$rev30d[] = ['date' => $shortDate, 'amount' => (int)($revMap[$date] ?? 0)];
|
||||
$days = max(1, (int) ceil(($to - $from) / 86400));
|
||||
$apptByDay = [];
|
||||
$revByDay = [];
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$ts = $from + $i * 86400;
|
||||
$date = date('Y-m-d', $ts);
|
||||
$shortDate = date('m/d', $ts);
|
||||
$apptByDay[] = ['date' => $shortDate, 'count' => (int)($apptMap[$date] ?? 0)];
|
||||
$revByDay[] = ['date' => $shortDate, 'amount' => (int)($revMap[$date] ?? 0)];
|
||||
}
|
||||
|
||||
$statusRows = $conn->fetchAllAssociative(
|
||||
@@ -1729,11 +1732,27 @@ class AdminApiController extends BaseController
|
||||
GROUP BY s.id, s.name ORDER BY cnt DESC LIMIT 8'
|
||||
);
|
||||
|
||||
$subRows = $conn->fetchAllAssociative(
|
||||
'SELECT sp.name AS plan_name, COUNT(cs.id) AS cnt, COALESCE(SUM(p.amount_rials), 0) AS revenue
|
||||
FROM clinic_subscriptions cs
|
||||
JOIN subscription_plans sp ON sp.id = cs.plan_id
|
||||
LEFT JOIN payments p ON p.id = cs.payment_id AND p.status = :st
|
||||
WHERE cs.created_at BETWEEN :s AND :e
|
||||
GROUP BY sp.id, sp.name ORDER BY cnt DESC',
|
||||
['st' => 'received', 's' => $from, 'e' => $to]
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'appointments_30d' => $appt30d,
|
||||
'revenue_30d' => $rev30d,
|
||||
'appointment_status' => array_map(fn($r) => ['status' => $r['status'], 'count' => (int) $r['cnt']], $statusRows),
|
||||
'top_specialties' => array_map(fn($r) => ['name' => $r['name'], 'count' => (int) $r['cnt']], $specRows),
|
||||
'appointments_by_day' => $apptByDay,
|
||||
'revenue_by_day' => $revByDay,
|
||||
'appointment_status' => array_map(fn($r) => ['status' => $r['status'], 'count' => (int) $r['cnt']], $statusRows),
|
||||
'top_specialties' => array_map(fn($r) => ['name' => $r['name'], 'count' => (int) $r['cnt']], $specRows),
|
||||
'subscription_sales_by_plan' => array_map(fn($r) => [
|
||||
'plan' => $r['plan_name'],
|
||||
'count' => (int) $r['cnt'],
|
||||
'revenue' => (int) $r['revenue'],
|
||||
], $subRows),
|
||||
'period' => ['from' => $from, 'to' => $to],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Service\PatientService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\OptimisticLockException;
|
||||
@@ -24,6 +25,7 @@ class AppointmentController extends BaseController
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly PatientService $patientService,
|
||||
) {}
|
||||
|
||||
// ── Public: available slots ───────────────────────────────────────────────
|
||||
@@ -426,6 +428,10 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
|
||||
}
|
||||
|
||||
if ($newStatus === Appointment::STATUS_CONFIRMED) {
|
||||
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $appointment->toArray()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\ClinicService\Repository\ServiceSectionRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ClinicServiceController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ServiceSectionRepository $sectionRepo,
|
||||
private readonly ServiceItemRepository $itemRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
// ── Service Sections ─────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/service-sections', methods: ['GET'])]
|
||||
public function listSections(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$sections = array_map(
|
||||
fn(ServiceSection $s) => $s->toArray(),
|
||||
$this->sectionRepo->findByEntity($entityType, $entityId)
|
||||
);
|
||||
|
||||
return $this->success($sections);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-section', methods: ['POST'])]
|
||||
public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim($data['name'] ?? '');
|
||||
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422);
|
||||
}
|
||||
|
||||
$section = new ServiceSection($entityType, $entityId, $name);
|
||||
$this->sectionRepo->save($section);
|
||||
|
||||
return $this->success($section->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($uuid);
|
||||
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (isset($data['name']) && trim($data['name']) !== '') {
|
||||
$section->setName(trim($data['name']));
|
||||
}
|
||||
if (isset($data['active'])) {
|
||||
$section->setActive((bool) $data['active']);
|
||||
}
|
||||
|
||||
$this->sectionRepo->save($section);
|
||||
|
||||
return $this->success($section->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($uuid);
|
||||
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$this->sectionRepo->remove($section);
|
||||
|
||||
return $this->success(['message' => 'بخش حذف شد']);
|
||||
}
|
||||
|
||||
// ── Service Items ────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
|
||||
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($sectionUuid);
|
||||
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
fn(ServiceItem $i) => $i->toArray(),
|
||||
$this->itemRepo->findBySection($section)
|
||||
);
|
||||
|
||||
return $this->success($items);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$sectionUuid = $data['section_uuid'] ?? '';
|
||||
$name = trim($data['name'] ?? '');
|
||||
|
||||
if ($name === '' || $sectionUuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'section_uuid و name الزامی هستند', 422);
|
||||
}
|
||||
|
||||
$section = $this->sectionRepo->findByUuid($sectionUuid);
|
||||
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, 'بخش یافت نشد', 404);
|
||||
}
|
||||
|
||||
$item = new ServiceItem($section, $name, (int) ($data['price_rials'] ?? 0));
|
||||
|
||||
if (!empty($data['staff_uuid'])) {
|
||||
$staff = $this->staffRepo->findByUuid($data['staff_uuid']);
|
||||
if ($staff !== null) {
|
||||
$item->setStaff($staff);
|
||||
}
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
return $this->success($item->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); }
|
||||
if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); }
|
||||
if (isset($data['active'])) { $item->setActive((bool) $data['active']); }
|
||||
if (array_key_exists('staff_uuid', $data)) {
|
||||
$staff = $data['staff_uuid'] ? $this->staffRepo->findByUuid($data['staff_uuid']) : null;
|
||||
$item->setStaff($staff);
|
||||
}
|
||||
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
return $this->success($item->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->itemRepo->remove($item);
|
||||
} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException) {
|
||||
return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, ErrorCodes::message(ErrorCodes::ERR_SERVICE_ITEM_IN_USE), 409);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'سرویس حذف شد']);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
private function assertServicesGate(string $entityType, ?int $entityId): void
|
||||
{
|
||||
if ($entityId === null) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
|
||||
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'services')) {
|
||||
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function ownsSection(ServiceSection $section, string $entityType, ?int $entityId): bool
|
||||
{
|
||||
return $entityId !== null
|
||||
&& $section->getEntityType() === $entityType
|
||||
&& $section->getEntityId() === $entityId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Entity;
|
||||
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
|
||||
#[ORM\Table(name: 'service_items')]
|
||||
class ServiceItem
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceSection::class, inversedBy: 'items')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceSection $section;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicStaff $staff = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(name: 'price_rials', type: 'integer')]
|
||||
private int $priceRials = 0;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(ServiceSection $section, string $name, int $priceRials = 0)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->section = $section;
|
||||
$this->name = $name;
|
||||
$this->priceRials = $priceRials;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getSection(): ServiceSection { return $this->section; }
|
||||
public function getStaff(): ?ClinicStaff { return $this->staff; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getPriceRials(): int { return $this->priceRials; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; }
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'section_uuid' => $this->section->getUuid(),
|
||||
'staff_uuid' => $this->staff?->getUuid(),
|
||||
'staff_name' => $this->staff?->getFullName(),
|
||||
'name' => $this->name,
|
||||
'price_rials' => $this->priceRials,
|
||||
'active' => $this->active,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Entity;
|
||||
|
||||
use App\ClinicService\Repository\ServiceSectionRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ServiceSectionRepository::class)]
|
||||
#[ORM\Table(name: 'service_sections')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_section_entity')]
|
||||
class ServiceSection
|
||||
{
|
||||
#[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(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: ServiceItem::class, mappedBy: 'section', cascade: ['remove'])]
|
||||
private Collection $items;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $name)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->name = $name;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->items = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
'name' => $this->name,
|
||||
'active' => $this->active,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ServiceItemRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ServiceItem::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?ServiceItem
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findBySection(ServiceSection $section): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.section = :section')
|
||||
->setParameter('section', $section)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ServiceItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->persist($item);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(ServiceItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->remove($item);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\ClinicService\Repository;
|
||||
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ServiceSectionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ServiceSection::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?ServiceSection
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('s.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(ServiceSection $section): void
|
||||
{
|
||||
$this->getEntityManager()->persist($section);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(ServiceSection $section): void
|
||||
{
|
||||
$this->getEntityManager()->remove($section);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ namespace App\Dashboard\Controller;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -21,24 +25,30 @@ class DashboardController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly PatientRecordRepository $patientRecordRepo,
|
||||
private readonly PatientSessionRepository $patientSessionRepo,
|
||||
) {}
|
||||
|
||||
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_CLINIC')]
|
||||
public function clinic(#[CurrentUser] User $user): JsonResponse
|
||||
public function clinic(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$clinicId = $clinic->getId();
|
||||
$clinicId = $clinic->getId();
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
$monthStart = strtotime('first day of this month midnight');
|
||||
|
||||
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
|
||||
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
|
||||
|
||||
// آمار نوبتهای امروز و این ماه
|
||||
$stats = $this->em->createQuery('
|
||||
SELECT
|
||||
@@ -114,6 +124,10 @@ class DashboardController extends BaseController
|
||||
'todayEnd' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
|
||||
$smsBalance = $this->smsWalletService->getBalance('clinic', $clinicId);
|
||||
$uniquePatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, $from, $to);
|
||||
$revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to);
|
||||
|
||||
return $this->success([
|
||||
'clinic' => [
|
||||
'uuid' => $clinic->getUuid(),
|
||||
@@ -126,7 +140,11 @@ class DashboardController extends BaseController
|
||||
'today_appointments' => (int) ($stats['today_appointments'] ?? 0),
|
||||
'this_month_appointments' => $monthCount,
|
||||
'pending_invitations' => $pendingInvitations,
|
||||
'sms_wallet_balance' => $smsBalance,
|
||||
'unique_patients_count' => $uniquePatients,
|
||||
'revenue_period_rials' => $revenuePeriod,
|
||||
],
|
||||
'period' => ['from' => $from, 'to' => $to],
|
||||
'today_appointments' => $todayAppts,
|
||||
'doctors' => $doctors,
|
||||
]);
|
||||
@@ -136,7 +154,7 @@ class DashboardController extends BaseController
|
||||
|
||||
#[Route('/api/v1/dashboard/doctor', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_DOCTOR')]
|
||||
public function doctor(#[CurrentUser] User $user): JsonResponse
|
||||
public function doctor(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null) {
|
||||
@@ -150,6 +168,9 @@ class DashboardController extends BaseController
|
||||
$tmrEnd = strtotime('tomorrow midnight') + 86399;
|
||||
$monthStart = strtotime('first day of this month midnight');
|
||||
|
||||
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
|
||||
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
|
||||
|
||||
// آمار
|
||||
$todayCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
@@ -197,6 +218,10 @@ class DashboardController extends BaseController
|
||||
WHERE d.id = :doctorId
|
||||
')->setParameter('doctorId', $doctorId)->getArrayResult();
|
||||
|
||||
$smsBalance = $this->smsWalletService->getBalance('doctor', $doctorId);
|
||||
$uniquePatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
|
||||
$revenuePeriod = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
|
||||
|
||||
return $this->success([
|
||||
'doctor' => [
|
||||
'uuid' => $doctor->getUuid(),
|
||||
@@ -209,7 +234,11 @@ class DashboardController extends BaseController
|
||||
'this_month_appointments' => $monthCount,
|
||||
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
|
||||
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
|
||||
'sms_wallet_balance' => $smsBalance,
|
||||
'unique_patients_count' => $uniquePatients,
|
||||
'revenue_period_rials' => $revenuePeriod,
|
||||
],
|
||||
'period' => ['from' => $from, 'to' => $to],
|
||||
'today_appointments' => $todayAppts,
|
||||
'clinics' => $clinics,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Patient\Service\PatientService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class PatientController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientRecordRepository $recordRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/patients', methods: ['GET'])]
|
||||
public function list(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
|
||||
$search = $request->query->get('search') ?: null;
|
||||
|
||||
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search);
|
||||
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(PatientRecord $r) => $r->toArray(), $records),
|
||||
$total,
|
||||
$page,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$userUuid = trim($data['user_uuid'] ?? '');
|
||||
|
||||
if ($userUuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'user_uuid الزامی است', 422);
|
||||
}
|
||||
|
||||
$patient = $this->userRepo->findByUuid($userUuid);
|
||||
if ($patient === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
|
||||
if ($existing !== null) {
|
||||
return $this->success($existing->toArray());
|
||||
}
|
||||
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
|
||||
$this->recordRepo->save($record);
|
||||
|
||||
return $this->success($record->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
return $this->success($record->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])]
|
||||
public function sessions(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
|
||||
$sessions = $this->sessionRepo->findByRecord($record, $page, $limit);
|
||||
$total = $this->sessionRepo->countByRecord($record);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn($s) => $s->toArray(), $sessions),
|
||||
$total,
|
||||
$page,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
|
||||
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertPatientGate($entityType, $entityId);
|
||||
|
||||
$record = $this->recordRepo->findByUuid($uuid);
|
||||
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$session = $this->patientService->createSession($record, $data, $entityType, $entityId);
|
||||
|
||||
return $this->success($session->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
|
||||
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
|
||||
$session = $this->sessionRepo->findByUuid($uuid);
|
||||
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
|
||||
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
|
||||
if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); }
|
||||
|
||||
$this->sessionRepo->save($session);
|
||||
|
||||
return $this->success($session->toArray());
|
||||
}
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
private function assertPatientGate(string $entityType, ?int $entityId): void
|
||||
{
|
||||
if ($entityId === null) {
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
|
||||
}
|
||||
|
||||
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
|
||||
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
|
||||
}
|
||||
}
|
||||
|
||||
private function ownsRecord($record, string $entityType, ?int $entityId): bool
|
||||
{
|
||||
return $entityId !== null
|
||||
&& $record->getEntityType() === $entityType
|
||||
&& $record->getEntityId() === $entityId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: PatientRecordRepository::class)]
|
||||
#[ORM\Table(name: 'patient_records')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
|
||||
class PatientRecord
|
||||
{
|
||||
#[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(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(name: 'created_by_type', type: 'string', length: 15)]
|
||||
private string $createdByType;
|
||||
|
||||
#[ORM\Column(name: 'created_by_id', type: 'integer')]
|
||||
private int $createdById;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: PatientSession::class, mappedBy: 'record', cascade: ['remove'])]
|
||||
private Collection $sessions;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, User $user, string $createdByType, int $createdById)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->user = $user;
|
||||
$this->createdByType = $createdByType;
|
||||
$this->createdById = $createdById;
|
||||
$this->createdAt = time();
|
||||
$this->sessions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getCreatedByType(): string { return $this->createdByType; }
|
||||
public function getCreatedById(): int { return $this->createdById; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
'user_uuid' => $this->user->getUuid(),
|
||||
'user_name' => $this->user->getRealName(),
|
||||
'user_mobile' => $this->user->getMobileNumber(),
|
||||
'created_by_type' => $this->createdByType,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: PatientSessionRepository::class)]
|
||||
#[ORM\Table(name: 'patient_sessions')]
|
||||
class PatientSession
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientRecord::class, inversedBy: 'sessions')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientRecord $record;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Appointment::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Appointment $appointment = null;
|
||||
|
||||
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
|
||||
private ?int $insuranceBaseId = null;
|
||||
|
||||
#[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)]
|
||||
private ?int $insuranceSupplementaryId = null;
|
||||
|
||||
#[ORM\Column(name: 'visit_price_rials', type: 'integer')]
|
||||
private int $visitPriceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'base_insurance_discount_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $baseInsuranceDiscountPercent = '0.00';
|
||||
|
||||
#[ORM\Column(name: 'supplementary_discount_percent', type: 'decimal', precision: 5, scale: 2)]
|
||||
private string $supplementaryDiscountPercent = '0.00';
|
||||
|
||||
#[ORM\Column(name: 'services_total_rials', type: 'integer')]
|
||||
private int $servicesTotalRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'final_price_rials', type: 'integer')]
|
||||
private int $finalPriceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'payment_method', type: 'string', length: 15)]
|
||||
private string $paymentMethod = 'pending';
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $notes = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: SessionService::class, mappedBy: 'session', cascade: ['remove'])]
|
||||
private Collection $services;
|
||||
|
||||
public function __construct(PatientRecord $record, ?Appointment $appointment = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->record = $record;
|
||||
$this->appointment = $appointment;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->services = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getRecord(): PatientRecord { return $this->record; }
|
||||
public function getAppointment(): ?Appointment { return $this->appointment; }
|
||||
public function getVisitPriceRials(): int { return $this->visitPriceRials; }
|
||||
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
|
||||
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
|
||||
public function getServicesTotalRials(): int { return $this->servicesTotalRials; }
|
||||
public function getFinalPriceRials(): int { return $this->finalPriceRials; }
|
||||
public function getPaymentMethod(): string { return $this->paymentMethod; }
|
||||
public function getNotes(): ?string { return $this->notes; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setInsuranceBaseId(?int $id): self { $this->insuranceBaseId = $id; $this->updatedAt = time(); return $this; }
|
||||
public function setInsuranceSupplementaryId(?int $id): self { $this->insuranceSupplementaryId = $id; $this->updatedAt = time(); return $this; }
|
||||
public function setVisitPriceRials(int $v): self { $this->visitPriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
|
||||
public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'record_uuid' => $this->record->getUuid(),
|
||||
'appointment_uuid' => $this->appointment?->getUuid(),
|
||||
'insurance_base_id' => $this->insuranceBaseId,
|
||||
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
|
||||
'visit_price_rials' => $this->visitPriceRials,
|
||||
'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent,
|
||||
'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent,
|
||||
'services_total_rials' => $this->servicesTotalRials,
|
||||
'final_price_rials' => $this->finalPriceRials,
|
||||
'payment_method' => $this->paymentMethod,
|
||||
'notes' => $this->notes,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Entity;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\Patient\Repository\SessionServiceRepository;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SessionServiceRepository::class)]
|
||||
#[ORM\Table(name: 'session_services')]
|
||||
class SessionService
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: PatientSession::class, inversedBy: 'services')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private PatientSession $session;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ServiceItem $serviceItem;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicStaff $staff = null;
|
||||
|
||||
#[ORM\Column(name: 'price_rials', type: 'integer')]
|
||||
private int $priceRials;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(PatientSession $session, ServiceItem $serviceItem, ?ClinicStaff $staff = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->session = $session;
|
||||
$this->serviceItem = $serviceItem;
|
||||
$this->staff = $staff;
|
||||
$this->priceRials = $serviceItem->getPriceRials();
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getSession(): PatientSession { return $this->session; }
|
||||
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
|
||||
public function getStaff(): ?ClinicStaff { return $this->staff; }
|
||||
public function getPriceRials(): int { return $this->priceRials; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'service_item_uuid' => $this->serviceItem->getUuid(),
|
||||
'service_name' => $this->serviceItem->getName(),
|
||||
'staff_uuid' => $this->staff?->getUuid(),
|
||||
'staff_name' => $this->staff?->getFullName(),
|
||||
'price_rials' => $this->priceRials,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientRecordRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientRecord::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientRecord
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByEntityAndUser(string $entityType, int $entityId, User $user): ?PatientRecord
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->join('r.user', 'u')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('r.id', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit);
|
||||
|
||||
if ($search !== null && $search !== '') {
|
||||
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search')
|
||||
->setParameter('search', '%' . $search . '%');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countByEntity(string $entityType, int $entityId, ?string $search = null): int
|
||||
{
|
||||
$qb = $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->join('r.user', 'u')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId);
|
||||
|
||||
if ($search !== null && $search !== '') {
|
||||
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search')
|
||||
->setParameter('search', '%' . $search . '%');
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function countUnique(string $entityType, int $entityId, int $from, int $to): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('r')
|
||||
->select('COUNT(DISTINCT r.user)')
|
||||
->join('r.sessions', 's')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->andWhere('s.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function save(PatientRecord $record): void
|
||||
{
|
||||
$this->getEntityManager()->persist($record);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PatientSessionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PatientSession::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?PatientSession
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->orderBy('s.id', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function countByRecord(PatientRecord $record): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('s')
|
||||
->select('COUNT(s.id)')
|
||||
->where('s.record = :record')
|
||||
->setParameter('record', $record)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function sumRevenue(string $entityType, int $entityId, int $from, int $to): int
|
||||
{
|
||||
$result = $this->createQueryBuilder('s')
|
||||
->select('SUM(s.finalPriceRials)')
|
||||
->join('s.record', 'r')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->andWhere('s.createdAt BETWEEN :from AND :to')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return (int) ($result ?? 0);
|
||||
}
|
||||
|
||||
public function save(PatientSession $session): void
|
||||
{
|
||||
$this->getEntityManager()->persist($session);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Repository;
|
||||
|
||||
use App\Patient\Entity\SessionService;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SessionServiceRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SessionService::class);
|
||||
}
|
||||
|
||||
public function save(SessionService $service): void
|
||||
{
|
||||
$this->getEntityManager()->persist($service);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Patient\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Entity\SessionService;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Patient\Repository\SessionServiceRepository;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
|
||||
class PatientService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientRecordRepository $recordRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly SessionServiceRepository $sessionServiceRepo,
|
||||
private readonly ServiceItemRepository $serviceItemRepo,
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
) {}
|
||||
|
||||
public function calculateFinalPrice(int $visitPrice, float $baseDiscount, float $suppDiscount, array $serviceItems): array
|
||||
{
|
||||
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
|
||||
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
|
||||
$servicesTotal = array_sum(array_column($serviceItems, 'price_rials'));
|
||||
|
||||
return [
|
||||
'services_total_rials' => (int) $servicesTotal,
|
||||
'final_price_rials' => (int) round($afterSupp) + (int) $servicesTotal,
|
||||
];
|
||||
}
|
||||
|
||||
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
|
||||
{
|
||||
$doctor = $appointment->getDoctor();
|
||||
$entityType = 'doctor';
|
||||
$entityId = $doctor->getId();
|
||||
|
||||
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$patient = $appointment->getUser();
|
||||
|
||||
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
|
||||
if ($record === null) {
|
||||
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $doctor->getId());
|
||||
$this->recordRepo->save($record);
|
||||
}
|
||||
|
||||
$session = new PatientSession($record, $appointment);
|
||||
$this->sessionRepo->save($session);
|
||||
}
|
||||
|
||||
public function createSession(
|
||||
PatientRecord $record,
|
||||
array $data,
|
||||
string $entityType,
|
||||
int $entityId
|
||||
): PatientSession {
|
||||
$session = new PatientSession($record);
|
||||
|
||||
if (!empty($data['appointment_uuid'])) {
|
||||
// appointment را از بیرون resolve میکنند و session را ست میکنند
|
||||
}
|
||||
|
||||
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
|
||||
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
|
||||
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
|
||||
$session->setBaseInsuranceDiscountPercent((float) ($data['base_insurance_discount_percent'] ?? 0));
|
||||
$session->setSupplementaryDiscountPercent((float) ($data['supplementary_discount_percent'] ?? 0));
|
||||
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
|
||||
$session->setNotes($data['notes'] ?? null);
|
||||
|
||||
// جمعآوری service items
|
||||
$serviceItemsData = [];
|
||||
foreach (($data['services'] ?? []) as $svc) {
|
||||
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
|
||||
if ($item !== null) {
|
||||
$serviceItemsData[] = ['price_rials' => $item->getPriceRials()];
|
||||
}
|
||||
}
|
||||
|
||||
$priceCalc = $this->calculateFinalPrice(
|
||||
$session->getVisitPriceRials(),
|
||||
$session->getBaseInsuranceDiscountPercent(),
|
||||
$session->getSupplementaryDiscountPercent(),
|
||||
$serviceItemsData
|
||||
);
|
||||
|
||||
$session->setServicesTotalRials($priceCalc['services_total_rials']);
|
||||
$session->setFinalPriceRials($priceCalc['final_price_rials']);
|
||||
|
||||
$this->sessionRepo->save($session);
|
||||
|
||||
// ثبت session services
|
||||
foreach (($data['services'] ?? []) as $svc) {
|
||||
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null;
|
||||
$ss = new SessionService($session, $item, $staff);
|
||||
$this->sessionServiceRepo->save($ss);
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace App\Payment\Controller;
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Payment\Gateway\MellatGateway;
|
||||
use App\Payment\Gateway\SepGateway;
|
||||
@@ -12,6 +14,8 @@ use App\Payment\Repository\PaymentRepository;
|
||||
use App\Payment\Service\CircuitBreakerService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
@@ -35,6 +39,10 @@ class PaymentController extends BaseController
|
||||
private readonly MellatGateway $mellat,
|
||||
private readonly SepGateway $sep,
|
||||
private readonly CircuitBreakerService $circuitBreaker,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly string $appBaseUrl,
|
||||
private readonly string $allowedFrontendHosts = '',
|
||||
) {}
|
||||
@@ -256,6 +264,12 @@ class PaymentController extends BaseController
|
||||
$payment->setReferenceId($result->referenceId);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
|
||||
$this->handleSubscriptionActivation($payment);
|
||||
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
|
||||
$this->handleSmsWalletCharge($payment);
|
||||
}
|
||||
|
||||
return $this->redirectToFrontend($payment, true);
|
||||
}
|
||||
|
||||
@@ -380,7 +394,11 @@ class PaymentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
|
||||
}
|
||||
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||
$periodUuid = trim($data['period_uuid'] ?? '');
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
|
||||
if ($periodUuid !== '') {
|
||||
$payment->setMetadata(['period_uuid' => $periodUuid]);
|
||||
}
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
@@ -540,6 +558,41 @@ class PaymentController extends BaseController
|
||||
return false;
|
||||
}
|
||||
|
||||
private function handleSmsWalletCharge(Payment $payment): void
|
||||
{
|
||||
$meta = $payment->getMetadata() ?? [];
|
||||
$entityType = $meta['entity_type'] ?? null;
|
||||
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
|
||||
|
||||
if ($entityType === null || $entityId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
|
||||
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
|
||||
}
|
||||
|
||||
private function handleSubscriptionActivation(Payment $payment): void
|
||||
{
|
||||
$meta = $payment->getMetadata() ?? [];
|
||||
$periodUuid = $meta['period_uuid'] ?? null;
|
||||
if ($periodUuid === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $payment->getUser();
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor !== null) {
|
||||
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
|
||||
return;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic !== null) {
|
||||
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
|
||||
}
|
||||
}
|
||||
|
||||
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$base = $payment->getFrontendAddress();
|
||||
|
||||
@@ -20,6 +20,7 @@ class Payment
|
||||
|
||||
public const TYPE_APPOINTMENT = 'appointment';
|
||||
public const TYPE_SUBSCRIPTION = 'subscription';
|
||||
public const TYPE_SMS_WALLET = 'sms_wallet';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
@@ -64,6 +65,9 @@ class Payment
|
||||
#[ORM\Column(name: 'callback_ip', type: 'string', length: 45, nullable: true)]
|
||||
private ?string $callbackIp = null;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $metadata = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
@@ -96,8 +100,10 @@ class Payment
|
||||
public function getReferenceId(): ?string { return $this->referenceId; }
|
||||
public function getFrontendAddress(): ?string { return $this->frontendAddress; }
|
||||
public function getCallbackIp(): ?string { return $this->callbackIp; }
|
||||
public function getMetadata(): ?array { return $this->metadata; }
|
||||
|
||||
public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; }
|
||||
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; $this->touch(); return $this; }
|
||||
public function setGatewayToken(?string $t): self { $this->gatewayToken = $t; $this->touch(); return $this; }
|
||||
public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; }
|
||||
public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; }
|
||||
|
||||
@@ -4,11 +4,13 @@ namespace App\Secretary\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
@@ -19,14 +21,13 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class SecretaryController extends BaseController
|
||||
{
|
||||
// TODO: link to subscription plan (Task 15). Basic plan = 1, advanced = 3
|
||||
private const MAX_SECRETARIES = 1;
|
||||
|
||||
public function __construct(
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/secretary', methods: ['POST'])]
|
||||
@@ -50,9 +51,12 @@ class SecretaryController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
// Check plan limit
|
||||
// Check plan limit (dynamic via SubscriptionService)
|
||||
$entityType = 'doctor';
|
||||
$entityId = $doctor->getId();
|
||||
$limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId);
|
||||
$activeCount = $this->secretaryRepo->countActiveByDoctor($doctor);
|
||||
if ($activeCount >= self::MAX_SECRETARIES) {
|
||||
if ($activeCount >= $limit) {
|
||||
return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,26 @@ class ErrorCodes
|
||||
// Secretary
|
||||
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
|
||||
|
||||
// Staff
|
||||
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
|
||||
|
||||
// Subscription
|
||||
public const ERR_SUBSCRIPTION_REQUIRED = 'ERR_SUBSCRIPTION_REQUIRED';
|
||||
public const ERR_TRIAL_ALREADY_USED = 'ERR_TRIAL_ALREADY_USED';
|
||||
public const ERR_TRIAL_DISABLED = 'ERR_TRIAL_DISABLED';
|
||||
public const ERR_SUBSCRIPTION_NOT_FOUND = 'ERR_SUBSCRIPTION_NOT_FOUND';
|
||||
|
||||
// Clinic Services
|
||||
public const ERR_SERVICE_ITEM_IN_USE = 'ERR_SERVICE_ITEM_IN_USE';
|
||||
public const ERR_SERVICE_NOT_FOUND = 'ERR_SERVICE_NOT_FOUND';
|
||||
|
||||
// Patient
|
||||
public const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND';
|
||||
public const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND';
|
||||
|
||||
// SMS Wallet
|
||||
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
|
||||
|
||||
// Rate Limit
|
||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||
|
||||
@@ -74,8 +94,18 @@ class ErrorCodes
|
||||
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
|
||||
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمیدهد',
|
||||
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
default => 'خطای ناشناخته',
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
self::ERR_STAFF_NOT_FOUND => 'پرسنل یافت نشد',
|
||||
self::ERR_SUBSCRIPTION_REQUIRED => 'این قابلیت نیاز به پنل Basic یا بالاتر دارد',
|
||||
self::ERR_TRIAL_ALREADY_USED => 'قبلاً از تریال استفاده کردهاید',
|
||||
self::ERR_TRIAL_DISABLED => 'تریال در حال حاضر غیرفعال است',
|
||||
self::ERR_SUBSCRIPTION_NOT_FOUND => 'اشتراک یافت نشد',
|
||||
self::ERR_SERVICE_ITEM_IN_USE => 'این سرویس در پرونده بیمار ثبت شده است',
|
||||
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
|
||||
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
|
||||
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
||||
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
||||
default => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Payment\Gateway\MellatGateway;
|
||||
use App\Payment\Gateway\SepGateway;
|
||||
use App\Payment\Repository\PaymentRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Entity\SmsSettings;
|
||||
use App\Sms\Repository\SmsSettingsRepository;
|
||||
use App\Sms\Repository\SmsWalletRepository;
|
||||
use App\Sms\Repository\SmsWalletTransactionRepository;
|
||||
use App\Sms\Service\SmsWalletService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class SmsWalletController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SmsWalletService $walletService,
|
||||
private readonly SmsWalletRepository $walletRepo,
|
||||
private readonly SmsWalletTransactionRepository $txRepo,
|
||||
private readonly SmsSettingsRepository $settingsRepo,
|
||||
private readonly PaymentRepository $paymentRepo,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly MellatGateway $mellat,
|
||||
private readonly SepGateway $sep,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly string $appBaseUrl,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/sms/wallet/balance', methods: ['GET'])]
|
||||
public function balance(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$balanceRials = $this->walletService->getBalance($entityType, $entityId);
|
||||
$smsPriceRials = (int) ($this->configRepo->get('sms_price_rials') ?? 500);
|
||||
$estimatedSms = $smsPriceRials > 0 ? (int) floor($balanceRials / $smsPriceRials) : 0;
|
||||
|
||||
return $this->success([
|
||||
'balance_rials' => $balanceRials,
|
||||
'sms_price_rials' => $smsPriceRials,
|
||||
'estimated_sms_count' => $estimatedSms,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/wallet/charge', methods: ['POST'])]
|
||||
public function charge(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$gatewayName = trim($data['gateway'] ?? 'mellat');
|
||||
$amountRials = (int) ($data['amount_rials'] ?? 0);
|
||||
|
||||
if ($amountRials <= 0) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$gateway = match ($gatewayName) {
|
||||
'mellat' => $this->mellat,
|
||||
'sep' => $this->sep,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($gateway === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$frontendAddress = trim($data['frontend_address'] ?? '');
|
||||
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
|
||||
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
|
||||
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
|
||||
|
||||
if (!$result->success) {
|
||||
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage ?? 'درگاه در دسترس نیست', 503);
|
||||
}
|
||||
|
||||
$payment->setGatewayToken($result->token);
|
||||
$this->paymentRepo->save($payment);
|
||||
|
||||
return $this->success([
|
||||
'payment_uuid' => $payment->getUuid(),
|
||||
'redirect_url' => $result->redirectUrl,
|
||||
'order_id' => $payment->getOrderId(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/wallet/logs', methods: ['GET'])]
|
||||
public function logs(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
|
||||
$wallet = $this->walletService->getOrCreate($entityType, $entityId);
|
||||
|
||||
$txs = $this->txRepo->findByWallet($wallet, $page, $limit);
|
||||
$total = $this->txRepo->countByWallet($wallet);
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn($tx) => $tx->toArray(), $txs),
|
||||
$total,
|
||||
$page,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/settings', methods: ['GET'])]
|
||||
public function getSettings(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$settings = $this->settingsRepo->findByEntity($entityType, $entityId);
|
||||
|
||||
if ($settings === null) {
|
||||
return $this->success([
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'reminder_enabled' => false,
|
||||
'reminder_hours_before' => 2,
|
||||
'post_visit_enabled' => false,
|
||||
'post_visit_text' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->success($settings->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/sms/settings', methods: ['PATCH'])]
|
||||
public function updateSettings(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$settings = $this->settingsRepo->findByEntity($entityType, $entityId);
|
||||
if ($settings === null) {
|
||||
$settings = new SmsSettings($entityType, $entityId);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['reminder_enabled'])) { $settings->setReminderEnabled((bool) $data['reminder_enabled']); }
|
||||
if (isset($data['reminder_hours_before'])) { $settings->setReminderHoursBefore((int) $data['reminder_hours_before']); }
|
||||
if (isset($data['post_visit_enabled'])) { $settings->setPostVisitEnabled((bool) $data['post_visit_enabled']); }
|
||||
if (array_key_exists('post_visit_text', $data)) { $settings->setPostVisitText($data['post_visit_text']); }
|
||||
|
||||
$this->settingsRepo->save($settings);
|
||||
|
||||
return $this->success($settings->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/sms/wallet-report', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminReport(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(10, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$total = (int) $this->walletRepo->createQueryBuilder('w')
|
||||
->select('COUNT(w.id)')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
$wallets = $this->walletRepo->createQueryBuilder('w')
|
||||
->orderBy('w.balanceRials', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return $this->paginated($wallets, $total, $page, $limit);
|
||||
}
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Entity;
|
||||
|
||||
use App\Sms\Repository\SmsSettingsRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SmsSettingsRepository::class)]
|
||||
#[ORM\Table(name: 'sms_settings')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_sms_settings_entity', columns: ['entity_type', 'entity_id'])]
|
||||
class SmsSettings
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'reminder_enabled', type: 'boolean')]
|
||||
private bool $reminderEnabled = false;
|
||||
|
||||
#[ORM\Column(name: 'reminder_hours_before', type: 'smallint')]
|
||||
private int $reminderHoursBefore = 2;
|
||||
|
||||
#[ORM\Column(name: 'post_visit_enabled', type: 'boolean')]
|
||||
private bool $postVisitEnabled = false;
|
||||
|
||||
#[ORM\Column(name: 'post_visit_text', type: 'text', nullable: true)]
|
||||
private ?string $postVisitText = null;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId)
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function isReminderEnabled(): bool { return $this->reminderEnabled; }
|
||||
public function getReminderHoursBefore(): int { return $this->reminderHoursBefore; }
|
||||
public function isPostVisitEnabled(): bool { return $this->postVisitEnabled; }
|
||||
public function getPostVisitText(): ?string { return $this->postVisitText; }
|
||||
|
||||
public function setReminderEnabled(bool $v): self { $this->reminderEnabled = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setReminderHoursBefore(int $v): self { $this->reminderHoursBefore = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setPostVisitEnabled(bool $v): self { $this->postVisitEnabled = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setPostVisitText(?string $v): self { $this->postVisitText = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
'reminder_enabled' => $this->reminderEnabled,
|
||||
'reminder_hours_before' => $this->reminderHoursBefore,
|
||||
'post_visit_enabled' => $this->postVisitEnabled,
|
||||
'post_visit_text' => $this->postVisitText,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Entity;
|
||||
|
||||
use App\Sms\Repository\SmsWalletRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SmsWalletRepository::class)]
|
||||
#[ORM\Table(name: 'sms_wallets')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_sms_wallet_entity', columns: ['entity_type', 'entity_id'])]
|
||||
class SmsWallet
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'balance_rials', type: 'integer')]
|
||||
private int $balanceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId)
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getBalanceRials(): int { return $this->balanceRials; }
|
||||
|
||||
public function credit(int $amount): void
|
||||
{
|
||||
$this->balanceRials += $amount;
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function debit(int $amount): bool
|
||||
{
|
||||
if ($this->balanceRials < $amount) {
|
||||
return false;
|
||||
}
|
||||
$this->balanceRials -= $amount;
|
||||
$this->updatedAt = time();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Entity;
|
||||
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Sms\Repository\SmsWalletTransactionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SmsWalletTransactionRepository::class)]
|
||||
#[ORM\Table(name: 'sms_wallet_transactions')]
|
||||
class SmsWalletTransaction
|
||||
{
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SmsWallet::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private SmsWallet $wallet;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\Column(name: 'amount_rials', type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Payment::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Payment $payment = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(SmsWallet $wallet, string $type, int $amountRials, ?string $description = null, ?Payment $payment = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->wallet = $wallet;
|
||||
$this->type = $type;
|
||||
$this->amountRials = $amountRials;
|
||||
$this->description = $description;
|
||||
$this->payment = $payment;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getType(): string { return $this->type; }
|
||||
public function getAmountRials(): int { return $this->amountRials; }
|
||||
public function getDescription(): ?string { return $this->description; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'type' => $this->type,
|
||||
'amount_rials' => $this->amountRials,
|
||||
'description' => $this->description,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Repository;
|
||||
|
||||
use App\Sms\Entity\SmsSettings;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SmsSettingsRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SmsSettings::class);
|
||||
}
|
||||
|
||||
public function findByEntity(string $entityType, int $entityId): ?SmsSettings
|
||||
{
|
||||
return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]);
|
||||
}
|
||||
|
||||
public function save(SmsSettings $settings): void
|
||||
{
|
||||
$this->getEntityManager()->persist($settings);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Repository;
|
||||
|
||||
use App\Sms\Entity\SmsWallet;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SmsWalletRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SmsWallet::class);
|
||||
}
|
||||
|
||||
public function findByEntity(string $entityType, int $entityId): ?SmsWallet
|
||||
{
|
||||
return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]);
|
||||
}
|
||||
|
||||
public function getBalance(string $entityType, int $entityId): int
|
||||
{
|
||||
$wallet = $this->findByEntity($entityType, $entityId);
|
||||
return $wallet?->getBalanceRials() ?? 0;
|
||||
}
|
||||
|
||||
public function save(SmsWallet $wallet): void
|
||||
{
|
||||
$this->getEntityManager()->persist($wallet);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Repository;
|
||||
|
||||
use App\Sms\Entity\SmsWallet;
|
||||
use App\Sms\Entity\SmsWalletTransaction;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SmsWalletTransactionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SmsWalletTransaction::class);
|
||||
}
|
||||
|
||||
public function findByWallet(SmsWallet $wallet, int $page = 1, int $limit = 20): array
|
||||
{
|
||||
return $this->createQueryBuilder('t')
|
||||
->where('t.wallet = :wallet')
|
||||
->setParameter('wallet', $wallet)
|
||||
->orderBy('t.id', 'DESC')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function countByWallet(SmsWallet $wallet): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('t')
|
||||
->select('COUNT(t.id)')
|
||||
->where('t.wallet = :wallet')
|
||||
->setParameter('wallet', $wallet)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
public function save(SmsWalletTransaction $tx): void
|
||||
{
|
||||
$this->getEntityManager()->persist($tx);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Sms\Service;
|
||||
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Sms\Entity\SmsWallet;
|
||||
use App\Sms\Entity\SmsWalletTransaction;
|
||||
use App\Sms\Repository\SmsWalletRepository;
|
||||
use App\Sms\Repository\SmsWalletTransactionRepository;
|
||||
|
||||
class SmsWalletService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SmsWalletRepository $walletRepo,
|
||||
private readonly SmsWalletTransactionRepository $txRepo,
|
||||
) {}
|
||||
|
||||
public function getOrCreate(string $entityType, int $entityId): SmsWallet
|
||||
{
|
||||
$wallet = $this->walletRepo->findByEntity($entityType, $entityId);
|
||||
if ($wallet === null) {
|
||||
$wallet = new SmsWallet($entityType, $entityId);
|
||||
$this->walletRepo->save($wallet);
|
||||
}
|
||||
return $wallet;
|
||||
}
|
||||
|
||||
public function charge(SmsWallet $wallet, int $amountRials, Payment $payment): void
|
||||
{
|
||||
$wallet->credit($amountRials);
|
||||
$this->walletRepo->save($wallet);
|
||||
|
||||
$tx = new SmsWalletTransaction(
|
||||
$wallet,
|
||||
SmsWalletTransaction::TYPE_CREDIT,
|
||||
$amountRials,
|
||||
'شارژ کیف پیامک',
|
||||
$payment
|
||||
);
|
||||
$this->txRepo->save($tx);
|
||||
}
|
||||
|
||||
public function deduct(SmsWallet $wallet, int $amountRials, string $description): bool
|
||||
{
|
||||
if (!$wallet->debit($amountRials)) {
|
||||
return false;
|
||||
}
|
||||
$this->walletRepo->save($wallet);
|
||||
|
||||
$tx = new SmsWalletTransaction(
|
||||
$wallet,
|
||||
SmsWalletTransaction::TYPE_DEBIT,
|
||||
$amountRials,
|
||||
$description,
|
||||
null
|
||||
);
|
||||
$this->txRepo->save($tx);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getBalance(string $entityType, int $entityId): int
|
||||
{
|
||||
return $this->walletRepo->getBalance($entityType, $entityId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Staff\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class StaffController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicStaffRepository $staffRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/staff', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$staff = array_map(
|
||||
fn(ClinicStaff $s) => $s->toArray(),
|
||||
$this->staffRepo->findByEntity($entityType, $entityId)
|
||||
);
|
||||
|
||||
return $this->success($staff);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/staff', methods: ['POST'])]
|
||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$fullName = trim($data['full_name'] ?? '');
|
||||
|
||||
if ($fullName === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'full_name الزامی است', 422);
|
||||
}
|
||||
|
||||
$staff = new ClinicStaff($entityType, $entityId, $fullName);
|
||||
$staff->setPhone($data['phone'] ?? null);
|
||||
$staff->setJobTitle($data['job_title'] ?? null);
|
||||
$staff->setAddress($data['address'] ?? null);
|
||||
$staff->setNationalCode($data['national_code'] ?? null);
|
||||
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
return $this->success($staff->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/staff/{uuid}', methods: ['PATCH'])]
|
||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$staff = $this->staffRepo->findByUuid($uuid);
|
||||
if ($staff === null) {
|
||||
return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
if (!$this->ownsStaff($staff, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, ErrorCodes::message(ErrorCodes::ERR_FORBIDDEN_001), 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['full_name']) && trim($data['full_name']) !== '') {
|
||||
$staff->setFullName(trim($data['full_name']));
|
||||
}
|
||||
if (array_key_exists('phone', $data)) { $staff->setPhone($data['phone']); }
|
||||
if (array_key_exists('job_title', $data)) { $staff->setJobTitle($data['job_title']); }
|
||||
if (array_key_exists('address', $data)) { $staff->setAddress($data['address']); }
|
||||
if (array_key_exists('national_code', $data)){ $staff->setNationalCode($data['national_code']); }
|
||||
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
return $this->success($staff->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/staff/{uuid}/toggle', methods: ['PATCH'])]
|
||||
public function toggle(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$staff = $this->staffRepo->findByUuid($uuid);
|
||||
if ($staff === null) {
|
||||
return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
if (!$this->ownsStaff($staff, $user)) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, ErrorCodes::message(ErrorCodes::ERR_FORBIDDEN_001), 403);
|
||||
}
|
||||
|
||||
$staff->toggleActive();
|
||||
$this->staffRepo->save($staff);
|
||||
|
||||
return $this->success($staff->toArray());
|
||||
}
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
private function ownsStaff(ClinicStaff $staff, User $user): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
return $entityId !== null
|
||||
&& $staff->getEntityType() === $entityType
|
||||
&& $staff->getEntityId() === $entityId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Staff\Entity;
|
||||
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_staff')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
|
||||
class ClinicStaff
|
||||
{
|
||||
#[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(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(name: 'full_name', type: 'string', length: 200)]
|
||||
private string $fullName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(name: 'job_title', type: 'string', length: 100, nullable: true)]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address = null;
|
||||
|
||||
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
|
||||
private ?string $nationalCode = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $fullName)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->fullName = $fullName;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getFullName(): string { return $this->fullName; }
|
||||
public function getPhone(): ?string { return $this->phone; }
|
||||
public function getJobTitle(): ?string { return $this->jobTitle; }
|
||||
public function getAddress(): ?string { return $this->address; }
|
||||
public function getNationalCode(): ?string { return $this->nationalCode; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setFullName(string $fullName): self { $this->fullName = $fullName; $this->updatedAt = time(); return $this; }
|
||||
public function setPhone(?string $phone): self { $this->phone = $phone; $this->updatedAt = time(); return $this; }
|
||||
public function setJobTitle(?string $jobTitle): self { $this->jobTitle = $jobTitle; $this->updatedAt = time(); return $this; }
|
||||
public function setAddress(?string $address): self { $this->address = $address; $this->updatedAt = time(); return $this; }
|
||||
public function setNationalCode(?string $code): self { $this->nationalCode = $code; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toggleActive(): self
|
||||
{
|
||||
$this->active = !$this->active;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
'full_name' => $this->fullName,
|
||||
'phone' => $this->phone,
|
||||
'job_title' => $this->jobTitle,
|
||||
'address' => $this->address,
|
||||
'national_code' => $this->nationalCode,
|
||||
'active' => $this->active,
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Staff\Repository;
|
||||
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ClinicStaffRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClinicStaff::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?ClinicStaff
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByEntity(string $entityType, int $entityId, bool $activeOnly = false): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('s')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('s.fullName', 'ASC');
|
||||
|
||||
if ($activeOnly) {
|
||||
$qb->andWhere('s.active = true');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function save(ClinicStaff $staff): void
|
||||
{
|
||||
$this->getEntityManager()->persist($staff);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Subscription\Entity\SubscriptionPeriod;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class SubscriptionController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SubscriptionPlanRepository $planRepo,
|
||||
private readonly SubscriptionPeriodRepository $periodRepo,
|
||||
private readonly ClinicSubscriptionRepository $subscriptionRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
// ── Public ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/subscription/plans', methods: ['GET'])]
|
||||
public function plans(): JsonResponse
|
||||
{
|
||||
$plans = $this->planRepo->findAllActive();
|
||||
|
||||
return $this->success(array_map(
|
||||
fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true),
|
||||
$plans
|
||||
));
|
||||
}
|
||||
|
||||
// ── Authenticated ────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/subscription/my', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function my(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId);
|
||||
$usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId);
|
||||
|
||||
return $this->success([
|
||||
'subscription' => $subscription?->toArray(),
|
||||
'used_trial' => $usedTrial,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/subscription/trial', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function trial(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
if ($entityId === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$subscription = $this->subscriptionService->activateTrial($entityType, $entityId);
|
||||
} catch (AppException $e) {
|
||||
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
|
||||
}
|
||||
|
||||
return $this->success($subscription->toArray(), 201);
|
||||
}
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/subscription/plans', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminPlans(): JsonResponse
|
||||
{
|
||||
$plans = $this->planRepo->findAllActive();
|
||||
|
||||
return $this->paginated(
|
||||
array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans),
|
||||
count($plans),
|
||||
1,
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminCreatePlan(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
$name = trim($data['name'] ?? '');
|
||||
if ($name === '' || !isset($data['level'])) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422);
|
||||
}
|
||||
|
||||
$plan = new SubscriptionPlan(
|
||||
$name,
|
||||
(int) $data['level'],
|
||||
(int) ($data['max_secretaries'] ?? 1),
|
||||
$data['features'] ?? []
|
||||
);
|
||||
|
||||
$this->planRepo->save($plan);
|
||||
|
||||
return $this->success($plan->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/plan/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminUpdatePlan(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$plan = $this->planRepo->findByUuid($uuid);
|
||||
if ($plan === null) {
|
||||
return $this->error(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND), 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['name'])) { $plan->setName($data['name']); }
|
||||
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
|
||||
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); }
|
||||
if (isset($data['features'])) { $plan->setFeatures($data['features']); }
|
||||
if (isset($data['active'])) { $plan->setActive((bool) $data['active']); }
|
||||
|
||||
$this->planRepo->save($plan);
|
||||
|
||||
return $this->success($plan->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/period', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminCreatePeriod(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
$planUuid = $data['plan_uuid'] ?? '';
|
||||
$plan = $this->planRepo->findByUuid($planUuid);
|
||||
if ($plan === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پنل یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (empty($data['label']) || !isset($data['duration_months'])) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label و duration_months الزامی هستند', 422);
|
||||
}
|
||||
|
||||
$period = new SubscriptionPeriod(
|
||||
$plan,
|
||||
$data['label'],
|
||||
(int) $data['duration_months'],
|
||||
(int) ($data['price_rials'] ?? 0),
|
||||
(bool) ($data['is_trial'] ?? false)
|
||||
);
|
||||
$period->setSortOrder((int) ($data['sort_order'] ?? 0));
|
||||
|
||||
$this->periodRepo->save($period);
|
||||
|
||||
return $this->success($period->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['PATCH'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminUpdatePeriod(string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$period = $this->periodRepo->findByUuid($uuid);
|
||||
if ($period === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (isset($data['label'])) { $period->setLabel($data['label']); }
|
||||
if (isset($data['duration_months'])) { $period->setDurationMonths((int) $data['duration_months']); }
|
||||
if (isset($data['price_rials'])) { $period->setPriceRials((int) $data['price_rials']); }
|
||||
if (isset($data['active'])) { $period->setActive((bool) $data['active']); }
|
||||
if (isset($data['sort_order'])) { $period->setSortOrder((int) $data['sort_order']); }
|
||||
|
||||
$this->periodRepo->save($period);
|
||||
|
||||
return $this->success($period->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminDeletePeriod(string $uuid): JsonResponse
|
||||
{
|
||||
$period = $this->periodRepo->findByUuid($uuid);
|
||||
if ($period === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
|
||||
}
|
||||
|
||||
$period->setActive(false);
|
||||
$this->periodRepo->save($period);
|
||||
|
||||
return $this->success(['message' => 'دوره غیرفعال شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/subscription/report', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function adminReport(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(10, (int) $request->query->get('limit', 20)));
|
||||
|
||||
$total = (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Subscription\Entity\ClinicSubscription s')
|
||||
->getSingleScalarResult();
|
||||
|
||||
$subscriptions = $this->em->createQuery('
|
||||
SELECT s.uuid, s.entityType, s.entityId, s.isTrial, s.startsAt, s.expiresAt, s.createdAt,
|
||||
p.name AS plan_name, p.level AS plan_level
|
||||
FROM App\Subscription\Entity\ClinicSubscription s
|
||||
JOIN s.plan p
|
||||
ORDER BY s.id DESC
|
||||
')
|
||||
->setFirstResult(($page - 1) * $limit)
|
||||
->setMaxResults($limit)
|
||||
->getArrayResult();
|
||||
|
||||
return $this->paginated($subscriptions, $total, $page, $limit);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Entity;
|
||||
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ClinicSubscriptionRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_subscriptions')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'expires_at'], name: 'idx_subscription_entity_expires')]
|
||||
class ClinicSubscription
|
||||
{
|
||||
#[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(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private SubscriptionPlan $plan;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SubscriptionPeriod::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private SubscriptionPeriod $period;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Payment::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Payment $payment = null;
|
||||
|
||||
#[ORM\Column(name: 'is_trial', type: 'boolean')]
|
||||
private bool $isTrial = false;
|
||||
|
||||
#[ORM\Column(name: 'starts_at', type: 'integer')]
|
||||
private int $startsAt;
|
||||
|
||||
#[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)]
|
||||
private ?int $expiresAt = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
SubscriptionPlan $plan,
|
||||
SubscriptionPeriod $period,
|
||||
bool $isTrial = false,
|
||||
?int $expiresAt = null,
|
||||
?Payment $payment = null
|
||||
) {
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->plan = $plan;
|
||||
$this->period = $period;
|
||||
$this->isTrial = $isTrial;
|
||||
$this->startsAt = time();
|
||||
$this->expiresAt = $expiresAt;
|
||||
$this->payment = $payment;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getPlan(): SubscriptionPlan { return $this->plan; }
|
||||
public function getPeriod(): SubscriptionPeriod { return $this->period; }
|
||||
public function getPayment(): ?Payment { return $this->payment; }
|
||||
public function isTrial(): bool { return $this->isTrial; }
|
||||
public function getStartsAt(): int { return $this->startsAt; }
|
||||
public function getExpiresAt(): ?int { return $this->expiresAt; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->expiresAt === null || $this->expiresAt > time();
|
||||
}
|
||||
|
||||
public function getDaysRemaining(): ?int
|
||||
{
|
||||
if ($this->expiresAt === null) {
|
||||
return null;
|
||||
}
|
||||
$remaining = $this->expiresAt - time();
|
||||
return max(0, (int) ceil($remaining / 86400));
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'plan' => $this->plan->toArray(),
|
||||
'period' => $this->period->toArray(),
|
||||
'is_trial' => $this->isTrial,
|
||||
'starts_at' => $this->startsAt,
|
||||
'expires_at' => $this->expiresAt,
|
||||
'days_remaining' => $this->getDaysRemaining(),
|
||||
'is_active' => $this->isActive(),
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Entity;
|
||||
|
||||
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SubscriptionPeriodRepository::class)]
|
||||
#[ORM\Table(name: 'subscription_periods')]
|
||||
class SubscriptionPeriod
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SubscriptionPlan::class, inversedBy: 'periods')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private SubscriptionPlan $plan;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50)]
|
||||
private string $label;
|
||||
|
||||
#[ORM\Column(name: 'duration_months', type: 'smallint')]
|
||||
private int $durationMonths;
|
||||
|
||||
#[ORM\Column(name: 'price_rials', type: 'integer')]
|
||||
private int $priceRials = 0;
|
||||
|
||||
#[ORM\Column(name: 'is_trial', type: 'boolean')]
|
||||
private bool $isTrial = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'sort_order', type: 'smallint')]
|
||||
private int $sortOrder = 0;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(SubscriptionPlan $plan, string $label, int $durationMonths, int $priceRials, bool $isTrial = false)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->plan = $plan;
|
||||
$this->label = $label;
|
||||
$this->durationMonths = $durationMonths;
|
||||
$this->priceRials = $priceRials;
|
||||
$this->isTrial = $isTrial;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getPlan(): SubscriptionPlan { return $this->plan; }
|
||||
public function getLabel(): string { return $this->label; }
|
||||
public function getDurationMonths(): int { return $this->durationMonths; }
|
||||
public function getPriceRials(): int { return $this->priceRials; }
|
||||
public function isTrial(): bool { return $this->isTrial; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getSortOrder(): int { return $this->sortOrder; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setLabel(string $label): self { $this->label = $label; $this->updatedAt = time(); return $this; }
|
||||
public function setDurationMonths(int $v): self { $this->durationMonths = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setPriceRials(int $v): self { $this->priceRials = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
public function setSortOrder(int $order): self { $this->sortOrder = $order; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'plan_uuid' => $this->plan->getUuid(),
|
||||
'label' => $this->label,
|
||||
'duration_months'=> $this->durationMonths,
|
||||
'price_rials' => $this->priceRials,
|
||||
'is_trial' => $this->isTrial,
|
||||
'active' => $this->active,
|
||||
'sort_order' => $this->sortOrder,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Entity;
|
||||
|
||||
use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SubscriptionPlanRepository::class)]
|
||||
#[ORM\Table(name: 'subscription_plans')]
|
||||
class SubscriptionPlan
|
||||
{
|
||||
#[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, unique: true)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $level;
|
||||
|
||||
#[ORM\Column(name: 'max_secretaries', type: 'smallint')]
|
||||
private int $maxSecretaries = 1;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $features = [];
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $active = true;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: SubscriptionPeriod::class, mappedBy: 'plan')]
|
||||
private Collection $periods;
|
||||
|
||||
public function __construct(string $name, int $level, int $maxSecretaries, array $features)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->name = $name;
|
||||
$this->level = $level;
|
||||
$this->maxSecretaries = $maxSecretaries;
|
||||
$this->features = $features;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->periods = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getLevel(): int { return $this->level; }
|
||||
public function getMaxSecretaries(): int { return $this->maxSecretaries; }
|
||||
public function getFeatures(): array { return $this->features; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
public function getPeriods(): Collection { return $this->periods; }
|
||||
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setLevel(int $level): self { $this->level = $level; $this->updatedAt = time(); return $this; }
|
||||
public function setMaxSecretaries(int $v): self { $this->maxSecretaries = $v; $this->updatedAt = time(); return $this; }
|
||||
public function setFeatures(array $features): self { $this->features = $features; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function hasFeature(string $feature): bool
|
||||
{
|
||||
return (bool) ($this->features[$feature] ?? false);
|
||||
}
|
||||
|
||||
public function toArray(bool $withPeriods = false): array
|
||||
{
|
||||
$data = [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'level' => $this->level,
|
||||
'max_secretaries' => $this->maxSecretaries,
|
||||
'features' => $this->features,
|
||||
'active' => $this->active,
|
||||
];
|
||||
|
||||
if ($withPeriods) {
|
||||
$data['periods'] = array_map(
|
||||
fn(SubscriptionPeriod $p) => $p->toArray(),
|
||||
$this->periods->filter(fn(SubscriptionPeriod $p) => $p->isActive())->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Repository;
|
||||
|
||||
use App\Subscription\Entity\ClinicSubscription;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class ClinicSubscriptionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ClinicSubscription::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?ClinicSubscription
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findActive(string $entityType, int $entityId): ?ClinicSubscription
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->andWhere('s.expiresAt IS NULL OR s.expiresAt > :now')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('now', time())
|
||||
->orderBy('s.id', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function hasUsedTrial(string $entityType, int $entityId): bool
|
||||
{
|
||||
$count = $this->createQueryBuilder('s')
|
||||
->select('COUNT(s.id)')
|
||||
->where('s.entityType = :type')
|
||||
->andWhere('s.entityId = :id')
|
||||
->andWhere('s.isTrial = true')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
public function save(ClinicSubscription $subscription): void
|
||||
{
|
||||
$this->getEntityManager()->persist($subscription);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Repository;
|
||||
|
||||
use App\Subscription\Entity\SubscriptionPeriod;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SubscriptionPeriodRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SubscriptionPeriod::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?SubscriptionPeriod
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findTrialPeriodForPlan(SubscriptionPlan $plan): ?SubscriptionPeriod
|
||||
{
|
||||
return $this->findOneBy(['plan' => $plan, 'isTrial' => true, 'active' => true]);
|
||||
}
|
||||
|
||||
public function save(SubscriptionPeriod $period): void
|
||||
{
|
||||
$this->getEntityManager()->persist($period);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Repository;
|
||||
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class SubscriptionPlanRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, SubscriptionPlan::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?SubscriptionPlan
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function findByName(string $name): ?SubscriptionPlan
|
||||
{
|
||||
return $this->findOneBy(['name' => $name, 'active' => true]);
|
||||
}
|
||||
|
||||
public function findAllActive(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->where('p.active = true')
|
||||
->orderBy('p.level', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(SubscriptionPlan $plan): void
|
||||
{
|
||||
$this->getEntityManager()->persist($plan);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Subscription\Service;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Subscription\Entity\ClinicSubscription;
|
||||
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||
use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||
|
||||
class SubscriptionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicSubscriptionRepository $subscriptionRepo,
|
||||
private readonly SubscriptionPlanRepository $planRepo,
|
||||
private readonly SubscriptionPeriodRepository $periodRepo,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
) {}
|
||||
|
||||
public function getActiveSubscription(string $entityType, int $entityId): ?ClinicSubscription
|
||||
{
|
||||
return $this->subscriptionRepo->findActive($entityType, $entityId);
|
||||
}
|
||||
|
||||
public function hasFeature(string $entityType, int $entityId, string $feature): bool
|
||||
{
|
||||
$subscription = $this->getActiveSubscription($entityType, $entityId);
|
||||
if ($subscription === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $subscription->getPlan()->hasFeature($feature);
|
||||
}
|
||||
|
||||
public function getSecretaryLimit(string $entityType, int $entityId): int
|
||||
{
|
||||
$subscription = $this->getActiveSubscription($entityType, $entityId);
|
||||
if ($subscription === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return $subscription->getPlan()->getMaxSecretaries();
|
||||
}
|
||||
|
||||
public function hasUsedTrial(string $entityType, int $entityId): bool
|
||||
{
|
||||
return $this->subscriptionRepo->hasUsedTrial($entityType, $entityId);
|
||||
}
|
||||
|
||||
public function activateTrial(string $entityType, int $entityId): ClinicSubscription
|
||||
{
|
||||
if ($this->hasUsedTrial($entityType, $entityId)) {
|
||||
throw new AppException(ErrorCodes::ERR_TRIAL_ALREADY_USED, null, 422);
|
||||
}
|
||||
|
||||
$trialEnabled = $this->configRepo->get('trial_enabled');
|
||||
if ($trialEnabled === '0') {
|
||||
throw new AppException(ErrorCodes::ERR_TRIAL_DISABLED, null, 422);
|
||||
}
|
||||
|
||||
$basicPlan = $this->planRepo->findByName('basic');
|
||||
if ($basicPlan === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
|
||||
}
|
||||
|
||||
$trialPeriod = $this->periodRepo->findTrialPeriodForPlan($basicPlan);
|
||||
if ($trialPeriod === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
|
||||
}
|
||||
|
||||
$expiresAt = $this->calculateExpiresAt(null, $trialPeriod->getDurationMonths());
|
||||
|
||||
$subscription = new ClinicSubscription(
|
||||
$entityType,
|
||||
$entityId,
|
||||
$basicPlan,
|
||||
$trialPeriod,
|
||||
true,
|
||||
$expiresAt,
|
||||
null
|
||||
);
|
||||
|
||||
$this->subscriptionRepo->save($subscription);
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
public function createFromPayment(Payment $payment, string $entityType, int $entityId, string $periodUuid): ClinicSubscription
|
||||
{
|
||||
$period = $this->periodRepo->findByUuid($periodUuid);
|
||||
if ($period === null) {
|
||||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 404);
|
||||
}
|
||||
|
||||
$currentSub = $this->getActiveSubscription($entityType, $entityId);
|
||||
$currentExpires = $currentSub?->getExpiresAt();
|
||||
|
||||
$expiresAt = $this->calculateExpiresAt($currentExpires, $period->getDurationMonths());
|
||||
|
||||
$subscription = new ClinicSubscription(
|
||||
$entityType,
|
||||
$entityId,
|
||||
$period->getPlan(),
|
||||
$period,
|
||||
false,
|
||||
$expiresAt,
|
||||
$payment
|
||||
);
|
||||
|
||||
$this->subscriptionRepo->save($subscription);
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int
|
||||
{
|
||||
$base = max($currentExpiresAt ?? 0, time());
|
||||
return $base + $durationMonths * 30 * 86400;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user