feat(subscription): implement resource quota management based on subscription plans
This commit is contained in:
@@ -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<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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* سرویسهایی که یک منبع ارائه میدهد.
|
||||
*
|
||||
|
||||
@@ -19,12 +19,19 @@ export function useSubscription() {
|
||||
const effectivePlan = data?.data?.effective_plan ?? sub?.plan ?? null;
|
||||
const features: Record<string, boolean> = 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,
|
||||
};
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
@@ -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(<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<PageHeader
|
||||
title="منابع"
|
||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||
/* سقف پلن پیش از باز شدن فرم گفته میشود، نه بعد از پر کردنش: خطای ۴۲۲ ته کار
|
||||
همان اطلاعات را دیرتر و گرانتر میداد. تصمیم نهایی همچنان با سرور است. */
|
||||
action={
|
||||
canUpdate ? (
|
||||
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
{!quota.unlimited && (
|
||||
<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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -634,6 +634,8 @@ export interface SubscriptionPlan {
|
||||
name: string;
|
||||
level: number;
|
||||
max_secretaries: number;
|
||||
/** سقف منابع؛ `-1` یعنی بینهایت. */
|
||||
max_resources: number;
|
||||
features: Record<string, boolean>;
|
||||
active: boolean;
|
||||
periods: SubscriptionPeriod[];
|
||||
@@ -653,6 +655,7 @@ export interface MySubscriptionData {
|
||||
name: string;
|
||||
level: number;
|
||||
max_secretaries: number;
|
||||
max_resources: number;
|
||||
features: Record<string, boolean>;
|
||||
};
|
||||
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<string, boolean>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیکاند، و آن انتخابگر همیشه یک
|
||||
|
||||
@@ -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 | شرح |
|
||||
|
||||
@@ -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\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);
|
||||
|
||||
+30
-8
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => '۹']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user