From 3e7028d77aff1f0fb3878a7e7fb728b8ddab6ffc Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 19:38:49 +0330 Subject: [PATCH] feat(subscription): implement resource quota management based on subscription plans --- assets/admin/hooks/useResources.ts | 31 +++++ assets/admin/hooks/useSubscription.ts | 7 + assets/admin/pages/ResourcesPage.test.tsx | 58 +++++++++ assets/admin/pages/ResourcesPage.tsx | 26 +++- assets/admin/types/index.ts | 4 + docs/api/resource.md | 22 ++++ docs/api/subscription.md | 15 ++- migrations/Version20260804155309.php | 35 +++++ .../Controller/ResourceController.php | 16 +++ .../Repository/ClinicResourceRepository.php | 18 +++ src/Shared/Constant/ErrorCodes.php | 4 + .../Controller/SubscriptionController.php | 5 +- src/Subscription/Entity/SubscriptionPlan.php | 13 +- .../Service/SubscriptionService.php | 13 ++ tests/ApiTestCase.php | 38 ++++-- tests/Resource/ResourceQuotaTest.php | 121 ++++++++++++++++++ 16 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 migrations/Version20260804155309.php create mode 100644 tests/Resource/ResourceQuotaTest.php diff --git a/assets/admin/hooks/useResources.ts b/assets/admin/hooks/useResources.ts index 73436a9e..c629b181 100644 --- a/assets/admin/hooks/useResources.ts +++ b/assets/admin/hooks/useResources.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api, ApiError, type ApiResponse } from '../lib/api'; +import { useSubscription } from './useSubscription'; import type { ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill, } from '../types'; @@ -99,6 +100,36 @@ export function useResources(filters: ResourceFilters = {}) { }; } +/** + * سهمیهٔ منابع محیط — سقفش از پلن اشتراک می‌آید و مصرفش از فهرست منابع. + * + * فهرست عمداً بی‌فیلتر خوانده می‌شود: سهمیه به کل محیط مربوط است و شمردنِ ردیف‌های + * فیلترشدهٔ صفحه، با هر فیلتر یک عدد متفاوت می‌داد. کوئری با کلیدِ `[resources, {}]` + * همان کوئریِ حالتِ بی‌فیلترِ صفحه است، پس معمولاً رفت‌وبرگشت اضافه‌ای نمی‌سازد. + * + * تصمیم نهایی با سرور است؛ این فقط دکمه را پیش از رفتن به فرم می‌بندد. + */ +export function useResourceQuota() { + const { maxResources, planLoaded } = useSubscription(); + + const { data } = useQuery({ + queryKey: [RESOURCES_KEY, {}], + queryFn: () => api.get>('/api/v1/resources'), + }); + + const used = data?.data?.length ?? 0; + // سقفِ ناشناخته (نقش بی‌اشتراک، یا پاسخِ هنوز نرسیده) گیت نمی‌شود؛ سرور خودش + // ۴۲۲ می‌دهد و بستنِ دکمه بر اساس حدس، بدتر از بستنش دیرتر است. + const unlimited = !planLoaded || maxResources < 0; + + return { + used, + limit: maxResources, + unlimited, + atLimit: !unlimited && used >= maxResources, + }; +} + /** * سرویس‌هایی که یک منبع ارائه می‌دهد. * diff --git a/assets/admin/hooks/useSubscription.ts b/assets/admin/hooks/useSubscription.ts index a6790af3..4f0282b6 100644 --- a/assets/admin/hooks/useSubscription.ts +++ b/assets/admin/hooks/useSubscription.ts @@ -19,12 +19,19 @@ export function useSubscription() { const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null; const features: Record = effectivePlan?.features ?? {}; const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1; + // `-1` یعنی بی‌نهایت. + const maxResources: number = effectivePlan?.max_resources ?? 1; + // نقش‌هایی که اشتراک ندارند (ادمین) و لحظهٔ پیش از رسیدن پاسخ: سقف ناشناخته است و + // نباید با پیش‌فرضِ ۱ به‌جای کاربر تصمیم گرفت — گیت‌کردن کارِ سرور است. + const planLoaded = effectivePlan !== null; const hasPlan = sub !== null; return { subscription: sub, hasFeature: (key: string) => features[key] ?? false, maxSecretaries, + maxResources, + planLoaded, hasPlan, isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, }; diff --git a/assets/admin/pages/ResourcesPage.test.tsx b/assets/admin/pages/ResourcesPage.test.tsx index 48527fe6..8b53b06a 100644 --- a/assets/admin/pages/ResourcesPage.test.tsx +++ b/assets/admin/pages/ResourcesPage.test.tsx @@ -14,6 +14,7 @@ vi.mock('../hooks/usePermissions', () => ({ })); import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; import ResourcesPage from './ResourcesPage'; const get = api.get as ReturnType; @@ -126,4 +127,61 @@ describe('ResourcesPage', () => { expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument(); } }); + + /** + * سقف منابع از پلن اشتراک می‌آید. رسیدن به سقف باید همان‌جا گفته شود، نه با ۴۲۲ + * بعد از پر کردن فرم. + */ + describe('سقف منابع پلن', () => { + function mockWithPlan(maxResources: number, resources: unknown[]) { + // اشتراک فقط برای نقش‌های صاحب محیط خوانده می‌شود. + useAuthStore.setState({ primaryRole: 'clinic' }); + + get.mockImplementation((path: string) => { + if (path.startsWith('/api/v1/subscription/my')) { + return Promise.resolve({ + success: true, + data: { + subscription: null, + used_trial: false, + effective_plan: { name: 'free', level: 0, max_secretaries: 1, max_resources: maxResources, features: {} }, + }, + }); + } + if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] }); + if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] }); + if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: resources }); + return Promise.resolve({ success: true, data: [] }); + }); + } + + it('در سقف، دکمهٔ افزودن جای خود را به ارتقای پنل می‌دهد', async () => { + mockWithPlan(1, [resource]); + renderWithProviders(, { route: '/admin/resources' }); + + await waitFor(() => + expect(screen.getByRole('link', { name: 'ارتقای پنل' })).toHaveAttribute('href', '/admin/subscription'), + ); + expect(screen.queryByText('افزودن منبع')).not.toBeInTheDocument(); + expect(screen.getByText('۱ از ۱ منبع پلن فعلی')).toBeInTheDocument(); + }); + + it('زیر سقف، دکمهٔ افزودن و شمارندهٔ مصرف را نشان می‌دهد', async () => { + mockWithPlan(3, [resource]); + renderWithProviders(, { route: '/admin/resources' }); + + await waitFor(() => expect(screen.getByText('۱ از ۳ منبع پلن فعلی')).toBeInTheDocument()); + expect(screen.getByText('افزودن منبع')).toBeInTheDocument(); + }); + + it('پلن نامحدود نه شمارنده دارد نه سقف', async () => { + mockWithPlan(-1, [resource, { ...resource, uuid: 'r2', name: 'لیزر ۲' }]); + renderWithProviders(, { route: '/admin/resources' }); + + // تا فهرست و پلن هر دو ننشسته‌اند، «نبودِ» شمارنده چیزی ثابت نمی‌کند. + await waitFor(() => expect(screen.getByText('لیزر ۲')).toBeInTheDocument()); + expect(screen.getByText('افزودن منبع')).toBeInTheDocument(); + expect(screen.queryByText(/منبع پلن فعلی/)).not.toBeInTheDocument(); + }); + }); }); diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index 13a3ae4e..089bb403 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -5,9 +5,10 @@ import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; import SearchableSelect from '../components/ui/SearchableSelect'; import { ActiveBadge } from '../components/ui/StatusBadge'; +import { formatNumber } from '../lib/utils'; import { useUrlState } from '../hooks/useUrlState'; import { usePermissions } from '../hooks/usePermissions'; -import { useResources, useResourceTypes, useSkills } from '../hooks/useResources'; +import { useResourceQuota, useResources, useResourceTypes, useSkills } from '../hooks/useResources'; import ResourceFormModal from '../components/resources/ResourceFormModal'; import type { ClinicResource } from '../types'; import ResourcesSubNav from '../components/resources/ResourcesSubNav'; @@ -45,6 +46,8 @@ export default function ResourcesPage() { // ساخت تنها کاری است که به منبعِ موجود گره نمی‌خورد، پس تنها مودالی است که می‌ماند. const [createOpen, setCreateOpen] = useState(false); + const quota = useResourceQuota(); + const rows = useMemo(() => { const q = urlState.search.trim(); return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q)); @@ -102,11 +105,26 @@ export default function ResourcesPage() { setCreateOpen(true)}> - افزودن منبع - +
+ {!quota.unlimited && ( + + {formatNumber(quota.used)} از {formatNumber(quota.limit)} منبع پلن فعلی + + )} + {quota.atLimit ? ( + + ارتقای پنل + + ) : ( + + )} +
) : undefined } /> diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 1e26fec0..e0ec5e3f 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -634,6 +634,8 @@ export interface SubscriptionPlan { name: string; level: number; max_secretaries: number; + /** سقف منابع؛ `-1` یعنی بی‌نهایت. */ + max_resources: number; features: Record; active: boolean; periods: SubscriptionPeriod[]; @@ -653,6 +655,7 @@ export interface MySubscriptionData { name: string; level: number; max_secretaries: number; + max_resources: number; features: Record; }; period?: { label: string; duration_months: number }; @@ -667,6 +670,7 @@ export interface MySubscriptionData { name: string; level: number; max_secretaries: number; + max_resources: number; features: Record; } | null; } diff --git a/docs/api/resource.md b/docs/api/resource.md index 606c3d25..ced42237 100644 --- a/docs/api/resource.md +++ b/docs/api/resource.md @@ -193,6 +193,28 @@ | `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر | | `active` | bool | — | پیش‌فرض `true` | +### سقف منابع بر اساس پلن اشتراک (2026-08) + +ساخت منبع به سقفِ پلنِ **مؤثرِ** محیط محدود است — `subscription_plans.max_resources`: + +| پلن | سقف | +|---|---| +| بدون اشتراک فعال (`free`) | ۱ منبع | +| `basic` — شامل دورهٔ آزمایشی | ۳ منبع | +| `professional` | نامحدود (`-1`) | + +- شمارش روی **همهٔ** منابع همان جفتِ محیط است: فعال و غیرفعال، و منابعِ پلِ پزشک/پرسنل + هم شمرده می‌شوند. غیرفعال‌کردن جای خالی نمی‌سازد؛ فقط حذف می‌سازد. +- سقف پیش از هر اعتبارسنجی دیگری سنجیده می‌شود، پس بدنهٔ ناقص هم همین خطا را می‌گیرد. +- سقف فقط روی همین اندپوینت است. پلِ خودکارِ منبع برای پزشک/پرسنل مسدود نمی‌شود، ولی + در شمارش می‌آید. +- **۴۲۲ `ERR_RESOURCE_LIMIT_001`:** «پلن فعلی حداکثر N منبع را پشتیبانی می‌کند؛ برای + افزودن، پنل را ارتقا دهید». + +سقف در `GET /api/v1/subscription/my` زیر `effective_plan.max_resources` می‌آید؛ پنل با +همان و شمارشِ `GET /api/v1/resources` دکمهٔ افزودن را می‌بندد. تست: +`tests/Resource/ResourceQuotaTest.php`. + ### شعبه از منابع حذف شد (2026-08) منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیک‌اند، و آن انتخابگر همیشه یک diff --git a/docs/api/subscription.md b/docs/api/subscription.md index be9283cc..c34560f3 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -8,7 +8,9 @@ لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth). -> پلن `free`: همه امکانات (`patient_records`, `services`, `sms_panel`, `insurance`) فعال‌اند؛ تنها محدودیت آن تعداد منشی (`max_secretaries`) است. +> پلن `free`: همه امکانات (`patient_records`, `services`, `sms_panel`, `insurance`) فعال‌اند؛ محدودیت‌هایش عددی‌اند — تعداد منشی (`max_secretaries`) و تعداد منبع (`max_resources`). + +> `max_resources` سقف منابع محیط است. مقدار `-1` یعنی نامحدود. مقادیر شیپ‌شده: `free` = ۱، `basic` = ۳، `professional` = `-1`. اجرای این سقف در `POST /api/v1/resource` است — [resource.md](resource.md). **Response 200:** ```json @@ -20,6 +22,7 @@ "name": "free", "level": 0, "max_secretaries": 1, + "max_resources": 1, "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true }, "active": true, "periods": [] @@ -29,6 +32,7 @@ "name": "basic", "level": 1, "max_secretaries": 3, + "max_resources": 3, "features": { "patient_records": true, "services": true, "sms_panel": false }, "active": true, "periods": [ @@ -65,7 +69,7 @@ "data": { "subscription": { "uuid": "...", - "plan": { "name": "basic", "level": 1, "max_secretaries": 3, "features": {...} }, + "plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} }, "period": { "label": "یک ماهه", "duration_months": 1, "price_rials": 290000 }, "is_trial": false, "starts_at": 1718000000, @@ -74,7 +78,7 @@ "is_active": true }, "used_trial": false, - "effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "features": {...} } + "effective_plan": { "name": "basic", "level": 1, "max_secretaries": 3, "max_resources": 3, "features": {...} } } } ``` @@ -94,7 +98,7 @@ {"success":true,"data":{ "subscription": null, "used_trial": false, - "effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1 } + "effective_plan": { "features": { "patient_records": true, "…": true }, "max_secretaries": 1, "max_resources": 1 } }} ``` @@ -180,10 +184,13 @@ callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSu "name": "enterprise", "level": 3, "max_secretaries": 20, + "max_resources": -1, "features": { "patient_records": true, "services": true, "sms_panel": true } } ``` +`max_resources` اختیاری است و پیش‌فرضش `1` — پلنِ ناشناخته نباید بی‌صدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته می‌شود. + **خطاها:** | کد | HTTP | شرح | diff --git a/migrations/Version20260804155309.php b/migrations/Version20260804155309.php new file mode 100644 index 00000000..5c598995 --- /dev/null +++ b/migrations/Version20260804155309.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE subscription_plans ADD max_resources SMALLINT NOT NULL DEFAULT 1'); + $this->addSql("UPDATE subscription_plans SET max_resources = 1 WHERE name = 'free'"); + $this->addSql("UPDATE subscription_plans SET max_resources = 3 WHERE name = 'basic'"); + $this->addSql("UPDATE subscription_plans SET max_resources = -1 WHERE name = 'professional'"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE subscription_plans DROP max_resources'); + } +} diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php index 662cb776..186eee76 100644 --- a/src/Resource/Controller/ResourceController.php +++ b/src/Resource/Controller/ResourceController.php @@ -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'); } diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php index b6ffb80d..662be2c7 100644 --- a/src/Resource/Repository/ClinicResourceRepository.php +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -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 { diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 2280e426..0dd6ab7e 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -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 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید', diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index 474edf5f..96fac106 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -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']); } diff --git a/src/Subscription/Entity/SubscriptionPlan.php b/src/Subscription/Entity/SubscriptionPlan.php index b80b6e92..e6381f4b 100644 --- a/src/Subscription/Entity/SubscriptionPlan.php +++ b/src/Subscription/Entity/SubscriptionPlan.php @@ -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, ]; diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php index 4b300799..368d82f2 100644 --- a/src/Subscription/Service/SubscriptionService.php +++ b/src/Subscription/Service/SubscriptionService.php @@ -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); diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index b0158b22..5eb8bd2a 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -63,21 +63,43 @@ abstract class ApiTestCase extends WebTestCase * and the patient / service / insurance endpoints answer 403 instead of doing * their job. db_test is never reset, so the insert is idempotent. * - * Mirrors the row shipped in the dev database. + * Mirrors the row shipped in the dev database, except for max_resources: the + * fixture grants an unlimited resource quota so that the dozens of suites which + * merely need a room or a device are not rewritten into subscription tests. The + * real per-plan quota is exercised explicitly by ResourceQuotaTest, which lowers + * it with setPlanResourceQuota(). */ private function ensureFreePlan(): void { $repo = $this->em->getRepository(SubscriptionPlan::class); - if ($repo->findOneBy(['name' => 'free']) !== null) { + $plan = $repo->findOneBy(['name' => 'free']); + + if ($plan === null) { + $this->em->persist(new SubscriptionPlan('free', 0, 1, [ + 'patient_records' => true, + 'services' => true, + 'sms_panel' => true, + 'insurance' => true, + ], SubscriptionPlan::UNLIMITED)); + $this->em->flush(); + return; } - $this->em->persist(new SubscriptionPlan('free', 0, 1, [ - 'patient_records' => true, - 'services' => true, - 'sms_panel' => true, - 'insurance' => true, - ])); + // db_test is never reset, so a quota lowered by a previous case must be undone. + if ($plan->getMaxResources() !== SubscriptionPlan::UNLIMITED) { + $plan->setMaxResources(SubscriptionPlan::UNLIMITED); + $this->em->flush(); + } + } + + /** سقف منابعِ پلن مؤثرِ تست‌ها؛ `SubscriptionPlan::UNLIMITED` یعنی بی‌نهایت. */ + protected function setPlanResourceQuota(int $max, string $plan = 'free'): void + { + $entity = $this->em->getRepository(SubscriptionPlan::class)->findOneBy(['name' => $plan]); + self::assertNotNull($entity, sprintf('Plan "%s" is missing from the test database.', $plan)); + + $entity->setMaxResources($max); $this->em->flush(); } diff --git a/tests/Resource/ResourceQuotaTest.php b/tests/Resource/ResourceQuotaTest.php new file mode 100644 index 00000000..80f106ec --- /dev/null +++ b/tests/Resource/ResourceQuotaTest.php @@ -0,0 +1,121 @@ +setPlanResourceQuota(1); + + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + $this->createResource($user, $address, $type, ['name' => 'اتاق ۱']); + self::assertSame(201, $this->responseCode()); + + $body = $this->createResource($user, $address, $type, ['name' => 'اتاق ۲']); + + self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame(ErrorCodes::ERR_RESOURCE_LIMIT_001, $body['errors'][0]['code']); + } + + public function testBasicPlanAllowsThreeResourcesAndRejectsTheFourth(): void + { + $this->setPlanResourceQuota(3); + + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + foreach (['اتاق ۱', 'اتاق ۲', 'اتاق ۳'] as $name) { + $this->createResource($user, $address, $type, ['name' => $name]); + self::assertSame(201, $this->responseCode(), $name); + } + + $body = $this->createResource($user, $address, $type, ['name' => 'اتاق ۴']); + + self::assertSame(422, $this->responseCode()); + self::assertSame(ErrorCodes::ERR_RESOURCE_LIMIT_001, $body['errors'][0]['code']); + self::assertStringContainsString('۳', $this->toPersianDigits($body['errors'][0]['message'])); + } + + public function testProfessionalPlanIsUnlimited(): void + { + $this->setPlanResourceQuota(SubscriptionPlan::UNLIMITED); + + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + foreach (range(1, 5) as $i) { + $this->createResource($user, $address, $type, ['name' => "اتاق $i"]); + self::assertSame(201, $this->responseCode(), "resource #$i"); + } + } + + /** غیرفعال‌کردن منبع، جای خالی نمی‌سازد. */ + public function testDeactivatedResourcesStillCountTowardTheQuota(): void + { + $this->setPlanResourceQuota(1); + + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + $first = $this->createResource($user, $address, $type, ['name' => 'اتاق ۱']); + $this->authJson('PATCH', '/api/v1/resource/' . $first['data']['uuid'], $user, ['active' => false]); + self::assertSame(200, $this->responseCode()); + + $this->createResource($user, $address, $type, ['name' => 'اتاق ۲']); + self::assertSame(422, $this->responseCode()); + } + + /** حذف منبع، جا را واقعاً آزاد می‌کند — شمارش زندهٔ دیتابیس است نه شمارندهٔ ذخیره‌شده. */ + public function testDeletingAResourceFreesTheSlot(): void + { + $this->setPlanResourceQuota(1); + + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address); + + $first = $this->createResource($user, $address, $type, ['name' => 'اتاق ۱']); + $this->authJson('DELETE', '/api/v1/resource/' . $first['data']['uuid'], $user); + self::assertSame(200, $this->responseCode()); + + $this->createResource($user, $address, $type, ['name' => 'اتاق ۲']); + self::assertSame(201, $this->responseCode()); + } + + /** سقف مالِ همان محیط است؛ پر شدن یک کلینیک، کلینیک دیگر را قفل نمی‌کند. */ + public function testQuotaIsCountedPerTenantPair(): void + { + $this->setPlanResourceQuota(1); + + [$userA, , $addressA] = $this->clinicWithAddress(); + $this->createResource($userA, $addressA, $this->resourceType($addressA), ['name' => 'اتاق A']); + self::assertSame(201, $this->responseCode()); + + [$userB, , $addressB] = $this->clinicWithAddress('شعبهٔ کلینیک دوم'); + $this->createResource($userB, $addressB, $this->resourceType($addressB), ['name' => 'اتاق B']); + + self::assertSame(201, $this->responseCode()); + self::assertSame(1, $this->em->getRepository(ClinicResource::class)->countForPair( + $addressB->tenantEntityType(), + $addressB->tenantEntityId(), + )); + } + + private function toPersianDigits(string $value): string + { + return strtr($value, ['0' => '۰', '1' => '۱', '2' => '۲', '3' => '۳', '4' => '۴', + '5' => '۵', '6' => '۶', '7' => '۷', '8' => '۸', '9' => '۹']); + } +}