Implement SMS panel user flow and patient records system; add wallet charging, automatic reminders, and patient session management with detailed database schema and user flows.

This commit is contained in:
hamed
2026-06-14 21:15:40 +03:30
parent a72a6da621
commit b58aacc37f
28 changed files with 3169 additions and 0 deletions
@@ -0,0 +1,207 @@
# معماری — تسک ۱۱: پنل اشتراکی (Subscription Tiers)
## ساختار فایل‌ها
```
src/Subscription/
├── Controller/
│ └── SubscriptionController.php
├── Entity/
│ ├── SubscriptionPlan.php
│ ├── SubscriptionPeriod.php
│ └── ClinicSubscription.php
├── Repository/
│ ├── SubscriptionPlanRepository.php
│ ├── SubscriptionPeriodRepository.php
│ └── ClinicSubscriptionRepository.php
└── Service/
└── SubscriptionService.php
```
**فایل‌هایی که تغییر می‌کنند:**
- `src/Payment/Controller/PaymentController.php` — متد `subscriptionCallback()` باید پس از تأیید پرداخت، `ClinicSubscription` بسازد
## Entity: SubscriptionPlan
```php
#[ORM\Entity]
#[ORM\Table(name: 'subscription_plans')]
class SubscriptionPlan
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 30)]
private string $name; // 'free' | 'basic' | 'professional'
#[ORM\Column(type: 'smallint')]
private int $level; // 0 | 1 | 2
#[ORM\Column(type: 'smallint')]
private int $maxSecretaries; // 1 | 2 | 5
#[ORM\Column(type: 'json')]
private array $features; // {"patient_records": bool, "services": bool, "sms_panel": bool}
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
// OneToMany → SubscriptionPeriod
}
```
## Entity: SubscriptionPeriod
```php
#[ORM\Entity]
#[ORM\Table(name: 'subscription_periods')]
class SubscriptionPeriod
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)]
#[ORM\JoinColumn(name: 'plan_id', nullable: false, onDelete: 'CASCADE')]
private SubscriptionPlan $plan;
#[ORM\Column(type: 'string', length: 50)]
private string $label; // '۶ ماهه', 'تریال ۱ ماهه'
#[ORM\Column(type: 'smallint')]
private int $durationMonths;
#[ORM\Column(type: 'integer')]
private int $priceRials; // 0 برای تریال
#[ORM\Column(type: 'boolean')]
private bool $isTrial = false;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'smallint')]
private int $sortOrder = 0;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Entity: ClinicSubscription
```php
#[ORM\Entity]
#[ORM\Table(name: 'clinic_subscriptions')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'expires_at'], name: 'idx_clinic_sub_entity')]
class ClinicSubscription
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 10)]
private string $entityType; // 'doctor' | 'clinic'
#[ORM\Column(type: 'integer')]
private int $entityId;
#[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)]
#[ORM\JoinColumn(nullable: false)]
private SubscriptionPlan $plan;
#[ORM\ManyToOne(targetEntity: SubscriptionPeriod::class)]
#[ORM\JoinColumn(nullable: false)]
private SubscriptionPeriod $period;
#[ORM\ManyToOne(targetEntity: \App\Payment\Entity\Payment::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?\App\Payment\Entity\Payment $payment = null; // null برای تریال
#[ORM\Column(type: 'boolean')]
private bool $isTrial = false;
#[ORM\Column(type: 'integer')]
private int $startsAt;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $expiresAt = null; // null = بی‌نهایت (Free)
#[ORM\Column(type: 'integer')]
private int $createdAt;
}
```
## Service: SubscriptionService
```php
class SubscriptionService
{
public function getActiveSubscription(string $entityType, int $entityId): ?ClinicSubscription
{
// آخرین رکورد که expires_at > time() OR expires_at IS NULL
}
public function hasFeature(string $entityType, int $entityId, string $feature): bool
{
$sub = $this->getActiveSubscription($entityType, $entityId);
if (!$sub) return false;
return $sub->getPlan()->getFeatures()[$feature] ?? false;
}
public function getSecretaryLimit(string $entityType, int $entityId): int
{
$sub = $this->getActiveSubscription($entityType, $entityId);
return $sub ? $sub->getPlan()->getMaxSecretaries() : 1; // Free = 1
}
public function hasUsedTrial(string $entityType, int $entityId): bool
{
return $this->subscriptionRepo->existsTrial($entityType, $entityId);
}
public function activateTrial(string $entityType, int $entityId): ClinicSubscription
{
// بررسی: آیا قبلاً تریال استفاده شده؟ → throw AppException
// بررسی: آیا trial_enabled در SiteConfig فعال است؟
// ساخت ClinicSubscription با is_trial=true, payment=null
}
public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int
{
$base = max($currentExpiresAt ?? 0, time());
return $base + ($durationMonths * 30 * 86400);
}
}
```
## وابستگی PaymentController
```php
// src/Payment/Controller/PaymentController.php
// متد subscriptionCallback() — بعد از تأیید پرداخت:
$subscriptionService->createFromPayment($payment, $periodUuid, $entityType, $entityId);
// این متد:
// 1. Period را پیدا می‌کند
// 2. expires_at را محاسبه می‌کند (در صورت تمدید از expires_at قبلی)
// 3. ClinicSubscription جدید می‌سازد
// 4. persist/flush
```
@@ -0,0 +1,92 @@
# پایگاه داده — تسک ۱۱: پنل اشتراکی (Subscription Tiers)
## جدول: subscription_plans
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| name | VARCHAR(30) NOT NULL | `'free'` \| `'basic'` \| `'professional'` |
| level | TINYINT NOT NULL | 0, 1, یا 2 |
| max_secretaries | TINYINT NOT NULL | 1, 2، یا 5 |
| features | JSON NOT NULL | `{"patient_records": bool, "services": bool, "sms_panel": bool}` |
| active | TINYINT(1) NOT NULL DEFAULT 1 | |
| created_at | INT NOT NULL | Unix timestamp |
| updated_at | INT NOT NULL | Unix timestamp |
## جدول: subscription_periods
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| plan_id | INT NOT NULL FK→subscription_plans.id ON DELETE CASCADE | |
| label | VARCHAR(50) NOT NULL | مثال: `'۶ ماهه'`, `'تریال ۱ ماهه'` |
| duration_months | TINYINT NOT NULL | مدت به ماه |
| price_rials | INT NOT NULL | `0` برای تریال |
| is_trial | TINYINT(1) NOT NULL DEFAULT 0 | |
| active | TINYINT(1) NOT NULL DEFAULT 1 | |
| sort_order | TINYINT NOT NULL DEFAULT 0 | ترتیب نمایش |
| created_at | INT NOT NULL | |
| updated_at | INT NOT NULL | |
## جدول: clinic_subscriptions
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
| entity_id | INT NOT NULL | id دکتر یا کلینیک |
| plan_id | INT NOT NULL FK→subscription_plans.id | |
| period_id | INT NOT NULL FK→subscription_periods.id | |
| payment_id | INT NULL FK→payments.id ON DELETE SET NULL | `NULL` برای تریال |
| is_trial | TINYINT(1) NOT NULL DEFAULT 0 | |
| starts_at | INT NOT NULL | Unix timestamp |
| expires_at | INT NULL | `NULL` = بی‌نهایت (Free) |
| created_at | INT NOT NULL | |
## ایندکس‌ها
```sql
-- جستجوی سریع اشتراک فعال
CREATE INDEX idx_clinic_subscriptions_entity
ON clinic_subscriptions(entity_type, entity_id, expires_at);
-- جلوگیری از استفاده مکرر از تریال
-- توجه: این constraint روی is_trial=1 کار می‌کند چون is_trial=0 می‌تواند تکراری باشد
-- بنابراین در application check می‌کنیم نه UNIQUE index
CREATE INDEX idx_clinic_subscriptions_trial
ON clinic_subscriptions(entity_type, entity_id, is_trial);
```
## Seed Data (Migration اولیه)
```sql
-- سه پنل پایه — باید در migration ایجاد شوند
INSERT INTO subscription_plans (uuid, name, level, max_secretaries, features, active, created_at, updated_at) VALUES
(UUID(), 'free', 0, 1, '{"patient_records":false,"services":false,"sms_panel":false}', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(UUID(), 'basic', 1, 2, '{"patient_records":true,"services":true,"sms_panel":false}', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(UUID(), 'professional', 2, 5, '{"patient_records":true,"services":true,"sms_panel":true}', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- دوره‌های نمونه برای basic (ادمین بعداً قیمت‌ها را ویرایش می‌کند)
-- plan_id=2 = basic
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at) VALUES
(UUID(), 2, 'تریال ۱ ماهه', 1, 0, 1, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(UUID(), 2, '۱ ماهه', 1, 250000, 0, 1, 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(UUID(), 2, '۳ ماهه', 3, 690000, 0, 1, 2, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(UUID(), 2, '۶ ماهه', 6, 1200000, 0, 1, 3, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(UUID(), 2, '۱۲ ماهه', 12, 2000000, 0, 1, 4, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
```
## SiteConfig key های جدید
| کلید | نوع | مقدار پیش‌فرض | توضیح |
|------|-----|--------------|-------|
| `trial_enabled` | string `'1'`\|`'0'` | `'1'` | ادمین می‌تواند تریال را غیرفعال کند |
## نکات مهم
- `expires_at = NULL` فقط برای رکوردهای Free plan است — این‌ها بی‌نهایت معتبرند
- query اشتراک فعال: `WHERE entity_type=? AND entity_id=? AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP()) ORDER BY id DESC LIMIT 1`
- تریال check در application: `SELECT COUNT(*) FROM clinic_subscriptions WHERE entity_type=? AND entity_id=? AND is_trial=1`
@@ -0,0 +1,160 @@
# تسک ۱۱: پنل اشتراکی (Subscription Tiers)
## توضیح
پیاده‌سازی سیستم پنل‌های اشتراکی سه‌سطحی. ادمین دوره‌ها و قیمت‌ها را تعریف می‌کند.
این تسک **gate check** برای تسک‌های ۱۲، ۱۳، ۱۵ فراهم می‌کند.
**نکته مهم:** `/api/v1/subscription-payment` و callback آن از قبل در `PaymentController` موجود است.
در این تسک فقط باید بعد از callback موفق، `ClinicSubscription` ساخته شود + endpoint های Subscription خودش.
## سطوح پنل
| Level | Name | منشی | patient_records | services |
|-------|------|------|----------------|---------|
| 0 | free | ۱ | ❌ | ❌ |
| 1 | basic | ۲ | ✅ | ✅ |
| 2 | professional | ۵ | ✅ | ✅ |
## Endpoint ها
| متد | مسیر | Permission | توضیح |
|-----|------|-----------|-------|
| GET | `/api/v1/subscription/plans` | public | لیست پنل‌ها + دوره‌ها + قیمت |
| GET | `/api/v1/subscription/my` | doctor/clinic | اشتراک فعال + `used_trial` |
| POST | `/api/v1/subscription/trial` | doctor/clinic | فعال‌سازی تریال Basic (یک‌بار) |
| GET | `/api/v1/admin/subscription/plans` | ROLE_ADMIN | لیست مدیریت پنل‌ها |
| POST | `/api/v1/admin/subscription/plan` | ROLE_ADMIN | ایجاد پنل |
| PATCH | `/api/v1/admin/subscription/plan/{uuid}` | ROLE_ADMIN | ویرایش پنل |
| POST | `/api/v1/admin/subscription/period` | ROLE_ADMIN | افزودن دوره |
| PATCH | `/api/v1/admin/subscription/period/{uuid}` | ROLE_ADMIN | ویرایش دوره/قیمت |
| DELETE | `/api/v1/admin/subscription/period/{uuid}` | ROLE_ADMIN | غیرفعال‌سازی دوره |
| GET | `/api/v1/admin/subscription/report` | ROLE_ADMIN | گزارش فروش + تریال‌ها |
**موجود (تغییر نمی‌کند):**
- `POST /api/v1/subscription-payment` — شروع پرداخت (body: `{ period_uuid }`)
- `GET|POST /api/v1/subscription-payment/callback/{gateway}` — callback gateway
## پیش‌نیازها
- تسک ۰۲ (Auth/JWT)
- تسک ۱۵-payment (PaymentController — موجود)
## زمان تخمینی
۱۴ تا ۱۶ ساعت
## نمونه Request
### POST /api/v1/subscription/trial
```json
{}
```
(body خالی — entity از JWT گرفته می‌شود)
### POST /api/v1/admin/subscription/period
```json
{
"plan_uuid": "uuid-of-basic-plan",
"label": "۶ ماهه",
"duration_months": 6,
"price_rials": 1200000,
"is_trial": false,
"sort_order": 3
}
```
### POST /api/v1/subscription-payment (موجود)
```json
{
"period_uuid": "uuid-of-selected-period",
"gateway": "mellat"
}
```
## نمونه Response
### GET /api/v1/subscription/plans
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "basic",
"level": 1,
"max_secretaries": 2,
"features": { "patient_records": true, "services": true, "sms_panel": false },
"periods": [
{ "uuid": "...", "label": "تریال ۱ ماهه", "duration_months": 1, "price_rials": 0, "is_trial": true },
{ "uuid": "...", "label": "۱ ماهه", "duration_months": 1, "price_rials": 250000, "is_trial": false },
{ "uuid": "...", "label": "۶ ماهه", "duration_months": 6, "price_rials": 1200000, "is_trial": false }
]
}
]
}
```
### GET /api/v1/subscription/my
```json
{
"success": true,
"data": {
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true } },
"period": { "label": "۶ ماهه", "duration_months": 6 },
"is_trial": false,
"starts_at": 1718000000,
"expires_at": 1733360000,
"used_trial": true,
"days_remaining": 42
}
}
```
وقتی اشتراک فعال ندارد:
```json
{
"success": true,
"data": {
"plan": { "name": "free", "level": 0 },
"expires_at": null,
"used_trial": false
}
}
```
### POST /api/v1/subscription/trial (موفق)
```json
{
"success": true,
"data": {
"plan": "basic",
"starts_at": 1718000000,
"expires_at": 1720678400
}
}
```
### POST /api/v1/subscription/trial (خطا — قبلاً استفاده شده)
```json
{
"success": false,
"errors": [{ "code": "ERR_TRIAL_ALREADY_USED", "message": "قبلاً از تریال استفاده کرده‌اید" }]
}
```
## قوانین تریال
- فقط برای پنل **basic** (level=1)
- هر entity یک‌بار — constraint `UNIQUE(entity_type, entity_id, is_trial)` + مقدار `is_trial=1` در DB
- بدون پرداخت — `payment_id = null`
- بعد از انقضا → برگشت به Free (داده‌ها حفظ می‌شوند)
- ادمین می‌تواند تریال را کلاً غیرفعال کند: `SiteConfig.trial_enabled = false`
## قانون تمدید
```
expires_at جدید = max(expires_at فعلی, time()) + duration_months × 30 × 86400
```
یعنی اگر اشتراک هنوز منقضی نشده، تمدید از تاریخ انقضا محاسبه می‌شود (نه از now).
## gate check در تسک‌های بعدی
```php
// SubscriptionService::hasFeature('patient_records') → bool
// false → $this->error(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, '...', 403)
```
@@ -0,0 +1,126 @@
# جریان کاربری — تسک ۱۱: پنل اشتراکی
## جریان مشاهده پنل‌ها و خرید
```
کاربر وارد صفحه اشتراک می‌شود
GET /api/v1/subscription/my
→ نمایش پنل فعلی + تاریخ انقضا (شمسی) + days_remaining
→ اگر used_trial=false و trial_enabled=true → بنر تریال نمایش داده می‌شود
GET /api/v1/subscription/plans
→ جدول مقایسه پنل‌ها (free | basic | professional)
→ هر پنل: لیست دوره‌ها با قیمت — دروه تریال جدا نمایش داده می‌شود
├─► کاربر «تریال» انتخاب می‌کند:
│ │
│ ▼
│ POST /api/v1/subscription/trial
│ ├─► 200: فعال شد → صفحه refresh
│ └─► 400: قبلاً استفاده شده → toast خطا
└─► کاربر یک دوره پولی انتخاب می‌کند:
POST /api/v1/subscription-payment (موجود)
{ period_uuid, gateway: 'mellat' }
redirect به gateway بانک
GET|POST /api/v1/subscription-payment/callback/{gateway}
SubscriptionService::createFromPayment()
→ ClinicSubscription ساخته می‌شود
redirect به /admin/subscription?success=1
```
## جریان تمدید اشتراک
```
اشتراک ۶ ماهه فعال است (expires_at = آینده)
کاربر دوره جدید ۶ ماهه انتخاب می‌کند و پرداخت می‌کند
calculateExpiresAt():
base = max(expires_at_فعلی, now) ← از تاریخ انقضا (نه الان)
new_expires = base + 6 × 30 × 86400
اشتراک ۶ ماه دیگر تمدید می‌شود بدون اتلاف زمان باقی‌مانده
```
## جریان gate check در تسک‌های بعدی
```
کاربر Free تلاش می‌کند پرونده بیمار بسازد
POST /api/v1/patient
PatientController::create():
$hasFeature = $subscriptionService->hasFeature($entityType, $entityId, 'patient_records')
├─► false → 403 ERR_SUBSCRIPTION_REQUIRED
│ { "errors": [{ "code": "ERR_SUBSCRIPTION_REQUIRED",
│ "message": "این قابلیت نیاز به پنل Basic یا بالاتر دارد" }] }
└─► true → ادامه پردازش
```
## جریان مدیریت پنل‌ها توسط ادمین
```
ادمین وارد صفحه مدیریت اشتراک می‌شود
├─► تب «پنل‌ها»:
│ GET /api/v1/admin/subscription/plans
│ → جدول پنل‌ها با دوره‌ها و قیمت‌ها
│ → PATCH /api/v1/admin/subscription/period/{uuid} ← ویرایش inline قیمت
│ → POST /api/v1/admin/subscription/period ← افزودن دوره جدید
└─► تب «گزارش»:
GET /api/v1/admin/subscription/report?from=UNIX&to=UNIX
→ تعداد خریدها + مجموع درآمد + تعداد تریال‌ها
→ breakdown بر اساس plan و period
```
## هشدار انقضا (frontend)
```
در هر بار لود صفحه:
اگر days_remaining <= 7 و expires_at != null:
→ نمایش banner هشدار: «اشتراک شما X روز دیگر منقضی می‌شود — تمدید کنید»
```
## نمایش در Frontend (SubscriptionPage.tsx)
```
┌─────────────────────────────────────────────────────┐
│ 🎁 یک ماه تریال رایگان — فعال‌سازی │ ← فقط اگر used_trial=false
├─────────────────────────────────────────────────────┤
│ پنل فعلی: Basic | انقضا: ۱۴۰۵/۰۶/۲۳ | ۴۲ روز │
├───────────┬──────────────────┬─────────────────────┤
│ Free │ Basic ★ │ Professional │
│ رایگان │ پرونده بیمار ✅ │ پرونده بیمار ✅ │
│ ۱ منشی │ سرویس‌ها ✅ │ سرویس‌ها ✅ │
│ │ ۲ منشی │ ۵ منشی │
│ │ ┌──────────┐ │ │
│ │ │ ۱ ماهه │ │ [انتخاب دوره ▼] │
│ │ │ ۲۵۰,۰۰۰ │ │ │
│ │ │ ۶ ماهه │ │ │
│ │ │ ۱,۲۰۰,۰۰۰│ │ │
│ │ └──────────┘ │ │
│ │ [خرید اشتراک] │ [خرید اشتراک] │
└───────────┴──────────────────┴─────────────────────┘
```