208 lines
6.0 KiB
Markdown
208 lines
6.0 KiB
Markdown
# معماری — تسک ۱۱: پنل اشتراکی (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
|
||
```
|