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 { 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* سرویسهایی که یک منبع ارائه میدهد.
|
* سرویسهایی که یک منبع ارائه میدهد.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیکاند، و آن انتخابگر همیشه یک
|
منابع دامنهٔ «شعبه» ندارند: دستگاه و اتاق مالِ خودِ کلینیکاند، و آن انتخابگر همیشه یک
|
||||||
|
|||||||
@@ -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 | شرح |
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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