feat: add admin subscription granting feature

- Implemented the ability for admins to grant subscriptions to doctors and clinics without payment.
- Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions.
- Updated the subscription model to track the admin who granted the subscription.
- Enhanced the subscription report to include details about granted subscriptions.
- Introduced a new `is_granted` field to indicate if a subscription was granted by an admin.
- Updated the database schema to support the new functionality with a migration.
- Added tests to ensure the correct behavior of the subscription granting process.
This commit is contained in:
hamed
2026-08-09 13:43:30 +03:30
parent 60ccd5cc1d
commit a6a965a2aa
10 changed files with 874 additions and 29 deletions
@@ -287,6 +287,71 @@ class SubscriptionController extends BaseController
return $this->success(['message' => 'دوره غیرفعال شد']);
}
/**
* اعطای اشتراک به یک پزشک یا کلینیک، بدون پرداخت.
*
* مقصد با uuid گرفته می‌شود نه با id: id داخلی است و در هیچ پاسخِ ادمینی
* نمی‌آید، پس پنل چیزی برای فرستادن نداشت.
*/
#[Route('/api/v1/admin/subscription/grant', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminGrant(Request $request, #[CurrentUser] User $admin): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$entityType = (string) ($data['entity_type'] ?? '');
$entityUuid = (string) ($data['entity_uuid'] ?? '');
$periodUuid = (string) ($data['period_uuid'] ?? '');
if (!in_array($entityType, ['doctor', 'clinic'], true) || $entityUuid === '' || $periodUuid === '') {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'entity_type (doctor یا clinic) و entity_uuid و period_uuid الزامی هستند',
422,
);
}
$entityId = $entityType === 'doctor'
? $this->doctorRepo->findByUuid($entityUuid)?->getId()
: $this->clinicRepo->findByUuid($entityUuid)?->getId();
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقصد اشتراک یافت نشد', 404);
}
try {
$subscription = $this->subscriptionService->grant($entityType, $entityId, $periodUuid, $admin);
} catch (AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
return $this->success($subscription->toArray(), 201);
}
/**
* اشتراک فعالِ یک مقصد — پیش از اعطا، تا ادمین downgrade را ناخواسته انجام ندهد.
*/
#[Route('/api/v1/admin/subscription/active/{entityType}/{entityUuid}', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminActiveSubscription(string $entityType, string $entityUuid): JsonResponse
{
if (!in_array($entityType, ['doctor', 'clinic'], true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'entity_type باید doctor یا clinic باشد', 422);
}
$entityId = $entityType === 'doctor'
? $this->doctorRepo->findByUuid($entityUuid)?->getId()
: $this->clinicRepo->findByUuid($entityUuid)?->getId();
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقصد اشتراک یافت نشد', 404);
}
return $this->success([
'subscription' => $this->subscriptionService->getActiveSubscription($entityType, $entityId)?->toArray(),
]);
}
#[Route('/api/v1/admin/subscription/report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
@@ -294,21 +359,45 @@ class SubscriptionController extends BaseController
$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();
$conn = $this->em->getConnection();
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM clinic_subscriptions');
$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();
$offset = ($page - 1) * $limit;
return $this->paginated($subscriptions, $total, $page, $limit);
// نامِ مقصد با JOIN خام گرفته می‌شود، نه DQL: جفت (entity_type, entity_id)
// پلی‌مورفیک است و به هیچ association دکترینی وصل نیست.
$rows = $conn->fetchAllAssociative(
"SELECT s.uuid, s.entity_type, s.entity_id, s.is_trial, s.starts_at, s.expires_at, s.created_at,
s.granted_by_user_id, p.name AS plan_name, p.level AS plan_level,
d.name AS doctor_name, c.name AS clinic_name,
g.real_name AS granted_by_name, g.mobile_number AS granted_by_mobile
FROM clinic_subscriptions s
JOIN subscription_plans p ON p.id = s.plan_id
LEFT JOIN doctors d ON s.entity_type = 'doctor' AND d.id = s.entity_id
LEFT JOIN clinics c ON s.entity_type = 'clinic' AND c.id = s.entity_id
LEFT JOIN users g ON g.id = s.granted_by_user_id
ORDER BY s.id DESC
LIMIT $limit OFFSET $offset"
);
$items = array_map(fn(array $r) => [
'uuid' => $r['uuid'],
'entityType' => $r['entity_type'],
'entityId' => (int) $r['entity_id'],
'entityName' => $r['entity_type'] === 'doctor' ? $r['doctor_name'] : $r['clinic_name'],
'isTrial' => (bool) $r['is_trial'],
'isGranted' => $r['granted_by_user_id'] !== null,
'grantedBy' => $r['granted_by_user_id'] === null
? null
: ($r['granted_by_name'] ?: $r['granted_by_mobile']),
'startsAt' => (int) $r['starts_at'],
'expiresAt' => $r['expires_at'] === null ? null : (int) $r['expires_at'],
'createdAt' => (int) $r['created_at'],
'plan_name' => $r['plan_name'],
'plan_level' => (int) $r['plan_level'],
], $rows);
return $this->paginated($items, $total, $page, $limit);
}
// ── Helpers ─────────────────────────────────────────────────────────────