feat(subscription): implement resource quota management based on subscription plans

This commit is contained in:
hamed
2026-08-04 19:38:49 +03:30
parent 0dca245246
commit 3e7028d77a
16 changed files with 408 additions and 18 deletions
+31
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api'; import { api, ApiError, type ApiResponse } from '../lib/api';
import { useSubscription } from './useSubscription';
import type { import type {
ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill, ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill,
} from '../types'; } 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<ApiResponse<ClinicResource[]>>('/api/v1/resources'),
});
const used = data?.data?.length ?? 0;
// سقفِ ناشناخته (نقش بی‌اشتراک، یا پاسخِ هنوز نرسیده) گیت نمی‌شود؛ سرور خودش
// ۴۲۲ می‌دهد و بستنِ دکمه بر اساس حدس، بدتر از بستنش دیرتر است.
const unlimited = !planLoaded || maxResources < 0;
return {
used,
limit: maxResources,
unlimited,
atLimit: !unlimited && used >= maxResources,
};
}
/** /**
* سرویس‌هایی که یک منبع ارائه می‌دهد. * سرویس‌هایی که یک منبع ارائه می‌دهد.
* *
+7
View File
@@ -19,12 +19,19 @@ export function useSubscription() {
const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null; const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null;
const features: Record<string, boolean> = effectivePlan?.features ?? {}; const features: Record<string, boolean> = effectivePlan?.features ?? {};
const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1; const maxSecretaries: number = effectivePlan?.max_secretaries ?? 1;
// `-1` یعنی بی‌نهایت.
const maxResources: number = effectivePlan?.max_resources ?? 1;
// نقش‌هایی که اشتراک ندارند (ادمین) و لحظهٔ پیش از رسیدن پاسخ: سقف ناشناخته است و
// نباید با پیش‌فرضِ ۱ به‌جای کاربر تصمیم گرفت — گیت‌کردن کارِ سرور است.
const planLoaded = effectivePlan !== null;
const hasPlan = sub !== null; const hasPlan = sub !== null;
return { return {
subscription: sub, subscription: sub,
hasFeature: (key: string) => features[key] ?? false, hasFeature: (key: string) => features[key] ?? false,
maxSecretaries, maxSecretaries,
maxResources,
planLoaded,
hasPlan, hasPlan,
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
}; };
+58
View File
@@ -14,6 +14,7 @@ vi.mock('../hooks/usePermissions', () => ({
})); }));
import { api } from '../lib/api'; import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import ResourcesPage from './ResourcesPage'; import ResourcesPage from './ResourcesPage';
const get = api.get as ReturnType<typeof vi.fn>; const get = api.get as ReturnType<typeof vi.fn>;
@@ -126,4 +127,61 @@ describe('ResourcesPage', () => {
expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument(); 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(<ResourcesPage />, { 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(<ResourcesPage />, { route: '/admin/resources' });
await waitFor(() => expect(screen.getByText('۱ از ۳ منبع پلن فعلی')).toBeInTheDocument());
expect(screen.getByText('افزودن منبع')).toBeInTheDocument();
});
it('پلن نامحدود نه شمارنده دارد نه سقف', async () => {
mockWithPlan(-1, [resource, { ...resource, uuid: 'r2', name: 'لیزر ۲' }]);
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
// تا فهرست و پلن هر دو ننشسته‌اند، «نبودِ» شمارنده چیزی ثابت نمی‌کند.
await waitFor(() => expect(screen.getByText('لیزر ۲')).toBeInTheDocument());
expect(screen.getByText('افزودن منبع')).toBeInTheDocument();
expect(screen.queryByText(/منبع پلن فعلی/)).not.toBeInTheDocument();
});
});
}); });
+22 -4
View File
@@ -5,9 +5,10 @@ import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable'; import DataTable, { type Column } from '../components/ui/DataTable';
import SearchableSelect from '../components/ui/SearchableSelect'; import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge'; import { ActiveBadge } from '../components/ui/StatusBadge';
import { formatNumber } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState'; import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions'; 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 ResourceFormModal from '../components/resources/ResourceFormModal';
import type { ClinicResource } from '../types'; import type { ClinicResource } from '../types';
import ResourcesSubNav from '../components/resources/ResourcesSubNav'; import ResourcesSubNav from '../components/resources/ResourcesSubNav';
@@ -45,6 +46,8 @@ export default function ResourcesPage() {
// ساخت تنها کاری است که به منبعِ موجود گره نمی‌خورد، پس تنها مودالی است که می‌ماند. // ساخت تنها کاری است که به منبعِ موجود گره نمی‌خورد، پس تنها مودالی است که می‌ماند.
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const quota = useResourceQuota();
const rows = useMemo(() => { const rows = useMemo(() => {
const q = urlState.search.trim(); const q = urlState.search.trim();
return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q)); return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q));
@@ -102,11 +105,26 @@ export default function ResourcesPage() {
<PageHeader <PageHeader
title="منابع" title="منابع"
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان." description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
/* سقف پلن پیش از باز شدن فرم گفته می‌شود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
همان اطلاعات را دیرتر و گران‌تر می‌داد. تصمیم نهایی همچنان با سرور است. */
action={ action={
canUpdate ? ( canUpdate ? (
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<PlusIcon style={{ width: 16 }} /> افزودن منبع {!quota.unlimited && (
</button> <span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
{formatNumber(quota.used)} از {formatNumber(quota.limit)} منبع پلن فعلی
</span>
)}
{quota.atLimit ? (
<Link to="/admin/subscription" className="btn primary" style={{ textDecoration: 'none' }}>
ارتقای پنل
</Link>
) : (
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن منبع
</button>
)}
</div>
) : undefined ) : undefined
} }
/> />
+4
View File
@@ -634,6 +634,8 @@ export interface SubscriptionPlan {
name: string; name: string;
level: number; level: number;
max_secretaries: number; max_secretaries: number;
/** سقف منابع؛ `-1` یعنی بی‌نهایت. */
max_resources: number;
features: Record<string, boolean>; features: Record<string, boolean>;
active: boolean; active: boolean;
periods: SubscriptionPeriod[]; periods: SubscriptionPeriod[];
@@ -653,6 +655,7 @@ export interface MySubscriptionData {
name: string; name: string;
level: number; level: number;
max_secretaries: number; max_secretaries: number;
max_resources: number;
features: Record<string, boolean>; features: Record<string, boolean>;
}; };
period?: { label: string; duration_months: number }; period?: { label: string; duration_months: number };
@@ -667,6 +670,7 @@ export interface MySubscriptionData {
name: string; name: string;
level: number; level: number;
max_secretaries: number; max_secretaries: number;
max_resources: number;
features: Record<string, boolean>; features: Record<string, boolean>;
} | null; } | null;
} }
+22
View File
@@ -193,6 +193,28 @@
| `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر | | `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر |
| `active` | bool | — | پیش‌فرض `true` | | `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) ### شعبه از منابع حذف شد (2026-08)
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیک‌اند، و آن انتخابگر همیشه یک منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیک‌اند، و آن انتخابگر همیشه یک
+11 -4
View File
@@ -8,7 +8,9 @@
لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth). لیست پنل‌ها با دوره‌های فعال (عمومی — بدون 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:** **Response 200:**
```json ```json
@@ -20,6 +22,7 @@
"name": "free", "name": "free",
"level": 0, "level": 0,
"max_secretaries": 1, "max_secretaries": 1,
"max_resources": 1,
"features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true }, "features": { "patient_records": true, "services": true, "sms_panel": true, "insurance": true },
"active": true, "active": true,
"periods": [] "periods": []
@@ -29,6 +32,7 @@
"name": "basic", "name": "basic",
"level": 1, "level": 1,
"max_secretaries": 3, "max_secretaries": 3,
"max_resources": 3,
"features": { "patient_records": true, "services": true, "sms_panel": false }, "features": { "patient_records": true, "services": true, "sms_panel": false },
"active": true, "active": true,
"periods": [ "periods": [
@@ -65,7 +69,7 @@
"data": { "data": {
"subscription": { "subscription": {
"uuid": "...", "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 }, "period": { "label": "یک ماهه", "duration_months": 1, "price_rials": 290000 },
"is_trial": false, "is_trial": false,
"starts_at": 1718000000, "starts_at": 1718000000,
@@ -74,7 +78,7 @@
"is_active": true "is_active": true
}, },
"used_trial": false, "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":{ {"success":true,"data":{
"subscription": null, "subscription": null,
"used_trial": false, "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", "name": "enterprise",
"level": 3, "level": 3,
"max_secretaries": 20, "max_secretaries": 20,
"max_resources": -1,
"features": { "patient_records": true, "services": true, "sms_panel": true } "features": { "patient_records": true, "services": true, "sms_panel": true }
} }
``` ```
`max_resources` اختیاری است و پیش‌فرضش `1` — پلنِ ناشناخته نباید بی‌صدا نامحدود شود. مقدار `-1` یعنی نامحدود. در `PATCH` هم همین فیلد پذیرفته می‌شود.
**خطاها:** **خطاها:**
| کد | HTTP | شرح | | کد | HTTP | شرح |
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Per-plan resource quota (`subscription_plans.max_resources`).
*
* free = 1, basic = 3, professional = -1 (unlimited). The default of 1 is the
* conservative one: an unknown/custom plan must not silently grant unlimited.
*/
final class Version20260804155309 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add max_resources quota to subscription plans';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -11,6 +11,8 @@ use App\Resource\Service\ResourceService;
use App\Resource\Service\SkillAssignmentService; use App\Resource\Service\SkillAssignmentService;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController; use App\Shared\Controller\BaseController;
use App\Subscription\Entity\SubscriptionPlan;
use App\Subscription\Service\SubscriptionService;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -35,6 +37,7 @@ class ResourceController extends BaseController
private readonly \App\Appointment\Repository\AppointmentRepository $appointments, private readonly \App\Appointment\Repository\AppointmentRepository $appointments,
private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings, private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings,
private readonly \App\Resource\Service\ResourceFreeTimeCalculator $freeTime, private readonly \App\Resource\Service\ResourceFreeTimeCalculator $freeTime,
private readonly SubscriptionService $subscriptions,
) {} ) {}
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])] #[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); 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)) { if (!is_string($data['type_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid'); 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(); 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 public function countActiveForPair(string $entityType, int $entityId): int
{ {
+4
View File
@@ -63,6 +63,9 @@ class ErrorCodes
// Secretary // Secretary
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001'; public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
// Resource
public const ERR_RESOURCE_LIMIT_001 = 'ERR_RESOURCE_LIMIT_001';
// Staff // Staff
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND'; public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
public const ERR_STAFF_MOBILE_INVALID = 'ERR_STAFF_MOBILE_INVALID'; public const ERR_STAFF_MOBILE_INVALID = 'ERR_STAFF_MOBILE_INVALID';
@@ -161,6 +164,7 @@ class ErrorCodes
self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت', self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت',
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است', self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمی‌دهد', self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمی‌دهد',
self::ERR_RESOURCE_LIMIT_001 => 'پلن فعلی اجازه منبع بیشتر را نمی‌دهد',
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است', self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید', self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید',
self::ERR_CAPTCHA_001 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید', self::ERR_CAPTCHA_001 => 'تأیید امنیتی ناموفق بود. لطفاً صفحه را رفرش کنید و دوباره تلاش کنید',
@@ -83,6 +83,7 @@ class SubscriptionController extends BaseController
'effective_plan' => $effectivePlan === null ? null : [ 'effective_plan' => $effectivePlan === null ? null : [
'features' => $effectivePlan->getFeatures(), 'features' => $effectivePlan->getFeatures(),
'max_secretaries' => $effectivePlan->getMaxSecretaries(), 'max_secretaries' => $effectivePlan->getMaxSecretaries(),
'max_resources' => $effectivePlan->getMaxResources(),
], ],
]); ]);
} }
@@ -148,7 +149,8 @@ class SubscriptionController extends BaseController
$name, $name,
(int) $data['level'], (int) $data['level'],
(int) ($data['max_secretaries'] ?? 1), (int) ($data['max_secretaries'] ?? 1),
$data['features'] ?? [] $data['features'] ?? [],
(int) ($data['max_resources'] ?? 1)
); );
$this->planRepo->save($plan); $this->planRepo->save($plan);
@@ -176,6 +178,7 @@ class SubscriptionController extends BaseController
} }
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); } if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); } 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['features'])) { $plan->setFeatures($data['features']); }
if (isset($data['active'])) { $plan->setActive((bool) $data['active']); } if (isset($data['active'])) { $plan->setActive((bool) $data['active']); }
+12 -1
View File
@@ -12,6 +12,9 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Table(name: 'subscription_plans')] #[ORM\Table(name: 'subscription_plans')]
class SubscriptionPlan class SubscriptionPlan
{ {
/** مقدارِ «بی‌نهایت» برای سقف‌های عددی — منفی است تا با هیچ شمارشِ واقعی اشتباه نشود. */
public const UNLIMITED = -1;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
@@ -29,6 +32,10 @@ class SubscriptionPlan
#[ORM\Column(name: 'max_secretaries', type: 'smallint')] #[ORM\Column(name: 'max_secretaries', type: 'smallint')]
private int $maxSecretaries = 1; private int $maxSecretaries = 1;
/** سقف منابع محیط؛ `self::UNLIMITED` یعنی بی‌نهایت. */
#[ORM\Column(name: 'max_resources', type: 'smallint')]
private int $maxResources = 1;
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $features = []; private array $features = [];
@@ -44,12 +51,13 @@ class SubscriptionPlan
#[ORM\OneToMany(targetEntity: SubscriptionPeriod::class, mappedBy: 'plan')] #[ORM\OneToMany(targetEntity: SubscriptionPeriod::class, mappedBy: 'plan')]
private Collection $periods; 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->uuid = Uuid::v4()->toRfc4122();
$this->name = $name; $this->name = $name;
$this->level = $level; $this->level = $level;
$this->maxSecretaries = $maxSecretaries; $this->maxSecretaries = $maxSecretaries;
$this->maxResources = $maxResources;
$this->features = $features; $this->features = $features;
$this->createdAt = time(); $this->createdAt = time();
$this->updatedAt = time(); $this->updatedAt = time();
@@ -61,6 +69,7 @@ class SubscriptionPlan
public function getName(): string { return $this->name; } public function getName(): string { return $this->name; }
public function getLevel(): int { return $this->level; } public function getLevel(): int { return $this->level; }
public function getMaxSecretaries(): int { return $this->maxSecretaries; } public function getMaxSecretaries(): int { return $this->maxSecretaries; }
public function getMaxResources(): int { return $this->maxResources; }
public function getFeatures(): array { return $this->features; } public function getFeatures(): array { return $this->features; }
public function isActive(): bool { return $this->active; } public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; } 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 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 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 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 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 setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
@@ -85,6 +95,7 @@ class SubscriptionPlan
'name' => $this->name, 'name' => $this->name,
'level' => $this->level, 'level' => $this->level,
'max_secretaries' => $this->maxSecretaries, 'max_secretaries' => $this->maxSecretaries,
'max_resources' => $this->maxResources,
'features' => $this->features, 'features' => $this->features,
'active' => $this->active, 'active' => $this->active,
]; ];
@@ -50,6 +50,19 @@ class SubscriptionService
return $plan?->getMaxSecretaries() ?? 1; 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 public function hasUsedTrial(string $entityType, int $entityId): bool
{ {
return $this->subscriptionRepo->hasUsedTrial($entityType, $entityId); return $this->subscriptionRepo->hasUsedTrial($entityType, $entityId);
+30 -8
View File
@@ -63,21 +63,43 @@ abstract class ApiTestCase extends WebTestCase
* and the patient / service / insurance endpoints answer 403 instead of doing * and the patient / service / insurance endpoints answer 403 instead of doing
* their job. db_test is never reset, so the insert is idempotent. * 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 private function ensureFreePlan(): void
{ {
$repo = $this->em->getRepository(SubscriptionPlan::class); $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; return;
} }
$this->em->persist(new SubscriptionPlan('free', 0, 1, [ // db_test is never reset, so a quota lowered by a previous case must be undone.
'patient_records' => true, if ($plan->getMaxResources() !== SubscriptionPlan::UNLIMITED) {
'services' => true, $plan->setMaxResources(SubscriptionPlan::UNLIMITED);
'sms_panel' => true, $this->em->flush();
'insurance' => true, }
])); }
/** سقف منابعِ پلن مؤثرِ تست‌ها؛ `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(); $this->em->flush();
} }
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Tests\Resource;
use App\Resource\Entity\ClinicResource;
use App\Shared\Constant\ErrorCodes;
use App\Subscription\Entity\SubscriptionPlan;
/**
* سقف منابع بر اساس پلن اشتراک: free یک منبع، basic سه، professional بی‌نهایت.
*
* سقف روی همهٔ منابع محیط اعمال می‌شود — فعال و غیرفعال — وگرنه یک بار
* غیرفعال‌کردن، سقف را دور می‌زد.
*/
class ResourceQuotaTest extends ResourceTestCase
{
public function testFreePlanAllowsExactlyOneResource(): void
{
$this->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' => '۹']);
}
}