feat(subscription): implement resource quota management based on subscription plans
This commit is contained in:
@@ -11,6 +11,8 @@ use App\Resource\Service\ResourceService;
|
||||
use App\Resource\Service\SkillAssignmentService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -35,6 +37,7 @@ class ResourceController extends BaseController
|
||||
private readonly \App\Appointment\Repository\AppointmentRepository $appointments,
|
||||
private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings,
|
||||
private readonly \App\Resource\Service\ResourceFreeTimeCalculator $freeTime,
|
||||
private readonly SubscriptionService $subscriptions,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
|
||||
@@ -77,6 +80,19 @@ class ResourceController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
// سقف پلن پیش از هر اعتبارسنجی دیگری سنجیده میشود: کاربری که جا ندارد نباید
|
||||
// فرم را تا آخر پر کند و ته کار خطای بیربط بگیرد.
|
||||
[$entityType, $entityId] = $this->context->pair($user);
|
||||
$limit = $this->subscriptions->getResourceLimit($entityType, $entityId);
|
||||
|
||||
if ($limit !== SubscriptionPlan::UNLIMITED && $this->resources->countForPair($entityType, $entityId) >= $limit) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_RESOURCE_LIMIT_001,
|
||||
sprintf('پلن فعلی حداکثر %d منبع را پشتیبانی میکند؛ برای افزودن، پنل را ارتقا دهید', $limit),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_string($data['type_uuid'] ?? null)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid');
|
||||
}
|
||||
|
||||
@@ -64,6 +64,24 @@ class ClinicResourceRepository extends ServiceEntityRepository
|
||||
return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* همهٔ منابع یک محیط، فعال و غیرفعال — مبنای سقفِ پلن اشتراک.
|
||||
*
|
||||
* غیرفعالها هم شمرده میشوند وگرنه سقف با یک بار غیرفعالکردن دور زده میشد؛
|
||||
* منابعِ پلِ پزشک/پرسنل هم شمرده میشوند، چون از نظر محصول «منبع» همانقدر منبعاند.
|
||||
*/
|
||||
public function countForPair(string $entityType, int $entityId): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('r')
|
||||
->select('COUNT(r.id)')
|
||||
->where('r.entityType = :type')
|
||||
->andWhere('r.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/** منابع فعال یک محیط — شرط آمادگیِ حالت نوبتدهی منبعمحور. */
|
||||
public function countActiveForPair(string $entityType, int $entityId): int
|
||||
{
|
||||
|
||||
@@ -63,6 +63,9 @@ class ErrorCodes
|
||||
// Secretary
|
||||
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
|
||||
|
||||
// Resource
|
||||
public const ERR_RESOURCE_LIMIT_001 = 'ERR_RESOURCE_LIMIT_001';
|
||||
|
||||
// Staff
|
||||
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
|
||||
public const ERR_STAFF_MOBILE_INVALID = 'ERR_STAFF_MOBILE_INVALID';
|
||||
@@ -161,6 +164,7 @@ class ErrorCodes
|
||||
self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت',
|
||||
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
|
||||
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمیدهد',
|
||||
self::ERR_RESOURCE_LIMIT_001 => 'پلن فعلی اجازه منبع بیشتر را نمیدهد',
|
||||
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
|
||||
self::ERR_RATE_LIMIT_001 => 'درخواستهای زیاد. لطفاً بعداً تلاش کنید',
|
||||
self::ERR_CAPTCHA_001 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید',
|
||||
|
||||
@@ -83,6 +83,7 @@ class SubscriptionController extends BaseController
|
||||
'effective_plan' => $effectivePlan === null ? null : [
|
||||
'features' => $effectivePlan->getFeatures(),
|
||||
'max_secretaries' => $effectivePlan->getMaxSecretaries(),
|
||||
'max_resources' => $effectivePlan->getMaxResources(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -148,7 +149,8 @@ class SubscriptionController extends BaseController
|
||||
$name,
|
||||
(int) $data['level'],
|
||||
(int) ($data['max_secretaries'] ?? 1),
|
||||
$data['features'] ?? []
|
||||
$data['features'] ?? [],
|
||||
(int) ($data['max_resources'] ?? 1)
|
||||
);
|
||||
|
||||
$this->planRepo->save($plan);
|
||||
@@ -176,6 +178,7 @@ class SubscriptionController extends BaseController
|
||||
}
|
||||
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
|
||||
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); }
|
||||
if (isset($data['max_resources'])) { $plan->setMaxResources((int) $data['max_resources']); }
|
||||
if (isset($data['features'])) { $plan->setFeatures($data['features']); }
|
||||
if (isset($data['active'])) { $plan->setActive((bool) $data['active']); }
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\Table(name: 'subscription_plans')]
|
||||
class SubscriptionPlan
|
||||
{
|
||||
/** مقدارِ «بینهایت» برای سقفهای عددی — منفی است تا با هیچ شمارشِ واقعی اشتباه نشود. */
|
||||
public const UNLIMITED = -1;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -29,6 +32,10 @@ class SubscriptionPlan
|
||||
#[ORM\Column(name: 'max_secretaries', type: 'smallint')]
|
||||
private int $maxSecretaries = 1;
|
||||
|
||||
/** سقف منابع محیط؛ `self::UNLIMITED` یعنی بینهایت. */
|
||||
#[ORM\Column(name: 'max_resources', type: 'smallint')]
|
||||
private int $maxResources = 1;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $features = [];
|
||||
|
||||
@@ -44,12 +51,13 @@ class SubscriptionPlan
|
||||
#[ORM\OneToMany(targetEntity: SubscriptionPeriod::class, mappedBy: 'plan')]
|
||||
private Collection $periods;
|
||||
|
||||
public function __construct(string $name, int $level, int $maxSecretaries, array $features)
|
||||
public function __construct(string $name, int $level, int $maxSecretaries, array $features, int $maxResources = 1)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->name = $name;
|
||||
$this->level = $level;
|
||||
$this->maxSecretaries = $maxSecretaries;
|
||||
$this->maxResources = $maxResources;
|
||||
$this->features = $features;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
@@ -61,6 +69,7 @@ class SubscriptionPlan
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getLevel(): int { return $this->level; }
|
||||
public function getMaxSecretaries(): int { return $this->maxSecretaries; }
|
||||
public function getMaxResources(): int { return $this->maxResources; }
|
||||
public function getFeatures(): array { return $this->features; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
@@ -70,6 +79,7 @@ class SubscriptionPlan
|
||||
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 setMaxResources(int $v): self { $this->maxResources = $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; }
|
||||
|
||||
@@ -85,6 +95,7 @@ class SubscriptionPlan
|
||||
'name' => $this->name,
|
||||
'level' => $this->level,
|
||||
'max_secretaries' => $this->maxSecretaries,
|
||||
'max_resources' => $this->maxResources,
|
||||
'features' => $this->features,
|
||||
'active' => $this->active,
|
||||
];
|
||||
|
||||
@@ -50,6 +50,19 @@ class SubscriptionService
|
||||
return $plan?->getMaxSecretaries() ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* سقف منابعِ محیط — `SubscriptionPlan::UNLIMITED` یعنی بینهایت.
|
||||
*
|
||||
* نبودِ اشتراک به پلن `free` میرسد (`getEffectivePlan`)، پس همانجا سقف ۱ گرفته
|
||||
* میشود و لازم نیست «پرداختی دارد یا نه» جداگانه پرسیده شود.
|
||||
*/
|
||||
public function getResourceLimit(string $entityType, int $entityId): int
|
||||
{
|
||||
$plan = $this->getEffectivePlan($entityType, $entityId);
|
||||
|
||||
return $plan?->getMaxResources() ?? 1;
|
||||
}
|
||||
|
||||
public function hasUsedTrial(string $entityType, int $entityId): bool
|
||||
{
|
||||
return $this->subscriptionRepo->hasUsedTrial($entityType, $entityId);
|
||||
|
||||
Reference in New Issue
Block a user