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,172 @@
# معماری — تسک ۱۰: مدیریت پرسنل (Staff)
## ساختار فایل‌ها
```
src/Staff/
├── Controller/
│ └── StaffController.php
├── Entity/
│ └── ClinicStaff.php
├── Repository/
│ └── ClinicStaffRepository.php
└── Service/
└── StaffService.php
```
## Entity: ClinicStaff
```php
<?php
namespace App\Staff\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: \App\Staff\Repository\ClinicStaffRepository::class)]
#[ORM\Table(name: 'clinic_staff')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_clinic_staff_entity')]
class ClinicStaff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
// polymorphic — 'doctor' یا 'clinic'
#[ORM\Column(type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'string', length: 200)]
private string $fullName;
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $phone = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $jobTitle = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $address = null;
#[ORM\Column(type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
public function __construct()
{
$this->uuid = (string) Uuid::v4();
$this->createdAt = time();
$this->updatedAt = time();
}
// getters/setters ...
}
```
## Controller: StaffController
```php
<?php
namespace App\Staff\Controller;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/v1')]
class StaffController extends BaseController
{
#[Route('/staff', methods: ['GET'])]
public function list(): JsonResponse
{
// 1. از JWT: entity_type و entity_id (db_key + db_uuid) را بگیر
// 2. StaffRepository::findByEntity($entityType, $entityId) صدا بزن
// 3. return $this->success($items);
}
#[Route('/staff', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
// 1. validate: full_name اجباری
// 2. ClinicStaff جدید با entityType/entityId از JWT بساز
// 3. persist و flush
// 4. return $this->success($staff->toArray());
}
#[Route('/staff/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request): JsonResponse
{
// 1. find by uuid → 404 اگر نبود
// 2. بررسی مالکیت: staff->entityType/entityId == JWT claim → 403 اگر نبود
// 3. ویرایش فیلدهای موجود در body
// 4. return $this->success($staff->toArray());
}
#[Route('/staff/{uuid}/toggle', methods: ['PATCH'])]
public function toggle(string $uuid): JsonResponse
{
// 1. find → ownership check
// 2. active = !active
// 3. return $this->success(['active' => $staff->isActive()]);
}
}
```
## Service: StaffService
```php
<?php
namespace App\Staff\Service;
class StaffService
{
public function toArray(ClinicStaff $staff): array
{
return [
'uuid' => $staff->getUuid(),
'full_name' => $staff->getFullName(),
'phone' => $staff->getPhone(),
'job_title' => $staff->getJobTitle(),
'address' => $staff->getAddress(),
'national_code' => $staff->getNationalCode(),
'active' => $staff->isActive(),
'created_at' => $staff->getCreatedAt(),
];
}
}
```
## نحوه خواندن entity_type / entity_id از JWT
```php
// در BaseController یا trait:
// JWT payload شامل: db_key ('doctor'/'clinic') و db_uuid (uuid entity)
// باید uuid را به id تبدیل کنیم:
$entityType = $this->getUser()->getDbKey(); // 'doctor' یا 'clinic'
$entityUuid = $this->getUser()->getDbUuid();
// سپس از Repository مربوطه id واقعی را بگیریم
```
## وابستگی‌های آینده
- `src/ClinicService/Entity/ServiceItem.php``staff_id FK → clinic_staff.id`
- `src/Patient/Entity/SessionService.php``staff_id FK → clinic_staff.id`
@@ -0,0 +1,51 @@
# پایگاه داده — تسک ۱۰: مدیریت پرسنل (Staff)
## جدول: clinic_staff
| ستون | نوع | توضیح |
|------|-----|-------|
| 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 | FK به doctors.id یا clinics.id (bسته به entity_type) |
| full_name | VARCHAR(200) NOT NULL | نام و نام‌خانوادگی — اجباری |
| phone | VARCHAR(20) NULL | |
| job_title | VARCHAR(100) NULL | عنوان شغل |
| address | TEXT NULL | |
| national_code | CHAR(10) NULL | |
| active | TINYINT(1) NOT NULL DEFAULT 1 | غیرفعال = soft delete |
| created_at | INT NOT NULL | Unix timestamp |
| updated_at | INT NOT NULL | Unix timestamp |
## ایندکس‌ها
```sql
CREATE INDEX idx_clinic_staff_entity
ON clinic_staff(entity_type, entity_id, active);
```
## نکات مهم
- **هیچ FK خارجی** برای entity_id تعریف نمی‌شود — چون entity_type polymorphic است و می‌تواند doctor یا clinic باشد. مسئولیت یکپارچگی با لایه application است.
- `active = 0` به معنای حذف منطقی است — ردیف هرگز از جدول پاک نمی‌شود
- وقتی staff غیرفعال می‌شود، service_items و session_services قبلی دست‌نخورده می‌مانند
## Migration نمونه
```sql
CREATE TABLE clinic_staff (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
full_name VARCHAR(200) NOT NULL,
phone VARCHAR(20) NULL,
job_title VARCHAR(100) NULL,
address TEXT NULL,
national_code CHAR(10) NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
INDEX idx_clinic_staff_entity (entity_type, entity_id, active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
+107
View File
@@ -0,0 +1,107 @@
# تسک ۱۰: مدیریت پرسنل (Staff)
## توضیح
پیاده‌سازی سیستم مدیریت پرسنل کلینیک/مطب. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند.
این تسک **پیش‌نیاز مستقیم** تسک ۱۳ (سرویس‌ها) و تسک ۱۵ (پرونده بیمار) است.
## Endpoint ها
| متد | مسیر | توضیح | نیاز به Auth |
|-----|------|-------|-------------|
| GET | `/api/v1/staff` | لیست پرسنل context جاری | بله (Doctor/Clinic) |
| POST | `/api/v1/staff` | ایجاد پرسنل جدید | بله |
| PATCH | `/api/v1/staff/{uuid}` | ویرایش اطلاعات | بله (مالک) |
| PATCH | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) | بله (مالک) |
**مهم:** حذف سخت (`DELETE`) ممنوع است — تاریخچه سرویس‌ها و سشن‌های بیمار به پرسنل ارجاع دارند.
## پیش‌نیازها
- تسک ۰۲ (Auth — JWT + switch-context)
- تسک ۰۵ (Doctor entity)
- تسک ۰۶ (Clinic entity)
## زمان تخمینی
۶ تا ۸ ساعت
## نمونه Request
### POST /api/v1/staff
```json
{
"full_name": "علی رضایی",
"phone": "09121234567",
"job_title": "پرستار",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678"
}
```
### PATCH /api/v1/staff/{uuid}
```json
{
"full_name": "علی رضایی",
"job_title": "سرپرستار",
"phone": "09129999999"
}
```
### PATCH /api/v1/staff/{uuid}/toggle
```json
{}
```
(body خالی — فقط وضعیت active را toggle می‌کند)
## نمونه Response
### GET /api/v1/staff
```json
{
"success": true,
"data": [
{
"uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"full_name": "علی رضایی",
"phone": "09121234567",
"job_title": "پرستار",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678",
"active": true,
"created_at": 1718000000
}
]
}
```
### POST /api/v1/staff (موفق)
```json
{
"success": true,
"data": {
"uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"full_name": "علی رضایی",
"phone": "09121234567",
"job_title": "پرستار",
"address": null,
"national_code": "0012345678",
"active": true,
"created_at": 1718000000
}
}
```
### PATCH /api/v1/staff/{uuid}/toggle
```json
{
"success": true,
"data": { "active": false }
}
```
## کدهای خطا
| کد HTTP | توضیح |
|---------|-------|
| 401 | JWT معتبر نیست |
| 403 | کاربر مالک این پرسنل نیست |
| 404 | uuid پرسنل پیدا نشد |
| 422 | `full_name` خالی است |
@@ -0,0 +1,86 @@
# جریان کاربری — تسک ۱۰: مدیریت پرسنل (Staff)
## جریان ایجاد پرسنل جدید
```
مدیر کلینیک/دکتر وارد پنل می‌شود
GET /api/v1/staff
→ لیست پرسنل فعلی context جاری نمایش داده می‌شود
کاربر روی «افزودن پرسنل» کلیک می‌کند → Modal باز می‌شود
POST /api/v1/staff
{ full_name, phone?, job_title?, address?, national_code? }
├─► 422: full_name خالی است → نمایش خطا در فرم
└─► 201: پرسنل ایجاد شد → لیست refresh می‌شود
```
## جریان ویرایش پرسنل
```
کاربر روی آیکن ویرایش در جدول کلیک می‌کند
Modal با اطلاعات فعلی پر می‌شود
PATCH /api/v1/staff/{uuid}
{ full_name, phone, job_title, ... }
├─► 403: این پرسنل متعلق به شما نیست
└─► 200: ویرایش موفق → Modal بسته می‌شود
```
## جریان غیرفعال‌سازی (Soft Delete)
```
کاربر روی toggle در جدول کلیک می‌کند
PATCH /api/v1/staff/{uuid}/toggle
active: true → false یا false → true
در لیست: badge وضعیت تغییر می‌کند (فعال/غیرفعال)
```
## چرا حذف سخت ممنوع است
```
clinic_staff (active=false)
├── service_items.staff_id → هنوز به پرسنل ارجاع دارد
│ (تاریخچه سرویس‌ها حفظ می‌شود)
└── session_services.staff_id → سشن‌های قبلی بیمار
نام انجام‌دهنده را نشان می‌دهند
```
اگر پرسنل حذف سخت می‌شد:
- `service_items.staff_id` → NULL (انجام‌دهنده سرویس گم می‌شد)
- `session_services.staff_id` → NULL (تاریخچه ویزیت ناقص می‌شد)
## نمایش در Frontend (StaffPage.tsx)
```
┌─────────────────────────────────────────────────┐
│ پرسنل کلینیک [+ افزودن] │
├──────────┬──────────┬──────────┬────────────────┤
│ نام │ سمت │ تلفن │ وضعیت عملیات│
├──────────┼──────────┼──────────┼────────────────┤
│ علی رضایی│ پرستار │ 0912... │ ✅فعال ✏️ 🔘 │
│ مریم نوری│ منشی │ 0913... │ ❌غیرفعال✏️ 🔘│
└──────────┴──────────┴──────────┴────────────────┘
```
- ستون آخر: آیکن ویرایش (Modal) + toggle وضعیت
- غیرفعال‌ها با رنگ کمتر نمایش داده می‌شوند اما از لیست حذف نمی‌شوند
- فیلتر: «فقط فعال‌ها» / «همه» — query param `?active=1`
@@ -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 │
│ رایگان │ پرونده بیمار ✅ │ پرونده بیمار ✅ │
│ ۱ منشی │ سرویس‌ها ✅ │ سرویس‌ها ✅ │
│ │ ۲ منشی │ ۵ منشی │
│ │ ┌──────────┐ │ │
│ │ │ ۱ ماهه │ │ [انتخاب دوره ▼] │
│ │ │ ۲۵۰,۰۰۰ │ │ │
│ │ │ ۶ ماهه │ │ │
│ │ │ ۱,۲۰۰,۰۰۰│ │ │
│ │ └──────────┘ │ │
│ │ [خرید اشتراک] │ [خرید اشتراک] │
└───────────┴──────────────────┴─────────────────────┘
```
@@ -0,0 +1,71 @@
# معماری — تسک ۱۲: تکمیل منشی
## فایل‌های موجود که تغییر می‌کنند
```
src/Secretary/Controller/SecretaryController.php ← inject SubscriptionService + limit check
src/Shared/Constant/ErrorCodes.php ← ثابت جدید ERR_SECRETARY_LIMIT_REACHED
assets/admin/pages/SecretariesPage.tsx ← Modal permissions checkbox matrix
```
## تغییرات Backend
### SecretaryController — constructor injection
```php
public function __construct(
private readonly EntityManagerInterface $em,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly DoctorRepository $doctorRepo,
private readonly SubscriptionService $subscriptionService, // ← جدید
) {}
```
### SecretaryRepository — متد جدید countActive
```php
// src/Secretary/Repository/DoctorSecretaryRepository.php
public function countActive(int $doctorId): int
{
return (int) $this->createQueryBuilder('s')
->select('COUNT(s.id)')
->where('s.doctor = :doctor')
->andWhere('s.active = true')
->setParameter('doctor', $doctorId)
->getQuery()
->getSingleScalarResult();
}
```
## تغییرات Frontend
### SecretariesPage.tsx — ساختار Modal permissions
```tsx
// types جدید
interface PermissionsMatrix {
version: number;
resources: {
appointments?: { view: boolean; create: boolean; cancel: boolean; update_status: boolean };
addresses?: { view: boolean; create: boolean; update: boolean; delete: boolean };
clinic_info?: { view: boolean; update: boolean };
insurances?: { view: boolean; create: boolean; update: boolean; delete: boolean };
};
}
// component
function PermissionsEditor({ value, onChange }: {
value: PermissionsMatrix;
onChange: (v: PermissionsMatrix) => void;
}) {
// رندر جدول checkbox
// هر ردیف: نام بخش + چک‌باکس‌های action
// onChange ساختار را update می‌کند
}
```
### نکته: مدیریت خطای 403 در فرم ایجاد منشی
```tsx
// وقتی POST /api/v1/secretary با 403 برمی‌گردد:
// نمایش toast: "سقف منشی پنل رسیده است. برای افزایش به صفحه اشتراک بروید"
// دکمه "ارتقاء پنل" → navigate('/admin/subscription')
```
@@ -0,0 +1,59 @@
# پایگاه داده — تسک ۱۲: تکمیل منشی
## هیچ migration لازم نیست
جدول `doctor_secretaries` کامل است. فقط باید مطمئن شویم ستون `permissions` درست ذخیره می‌شود.
## ستون موجود: permissions در doctor_secretaries
| ستون | نوع | مقدار پیش‌فرض |
|------|-----|--------------|
| permissions | JSON | `DEFAULT_PERMISSIONS` از DoctorSecretary entity |
### ساختار JSON ذخیره‌شده:
```json
{
"version": 1,
"resources": {
"appointments": {
"view": true,
"create": true,
"cancel": false,
"update_status": true
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"clinic_info": {
"view": true,
"update": false
},
"insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
}
}
```
### نکته `version`:
- `version: 1` — اگر در آینده ساختار permissions تغییر کرد، migration به version بعدی انجام می‌شود
- همیشه هنگام خواندن permissions، `version` بررسی شود و اگر قدیمی بود با DEFAULT_PERMISSIONS merge شود
## جدول doctor_secretaries (مرجع)
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT PK | |
| uuid | CHAR(36) UNIQUE | |
| doctor_id | INT FK→doctors.id | |
| secretary_id | INT FK→users.id | |
| permissions | JSON | ساختار بالا |
| active | TINYINT(1) DEFAULT 1 | |
| created_at | INT | |
| updated_at | INT | |
@@ -0,0 +1,134 @@
# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
## توضیح
Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند:
1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
2. Frontend: Modal با checkbox matrix برای ویرایش permissions
**هیچ endpoint جدیدی ایجاد نمی‌شود.**
## فایل‌های موجود که تغییر می‌کنند
| فایل | تغییر |
|------|-------|
| `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` |
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
## API موجود (بدون تغییر)
| متد | مسیر | توضیح |
|-----|------|-------|
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیان |
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** |
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** |
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) |
## پیش‌نیازها
- تسک ۱۴ (Secretary — موجود در کد)
- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
## زمان تخمینی
۴ تا ۵ ساعت
## تغییر Backend — SecretaryController::create()
### وضعیت فعلی (قبل):
```php
public function create(Request $request): JsonResponse
{
// validate fields
// create DoctorSecretary
// persist
return $this->success($secretary->toArray());
}
```
### وضعیت جدید (بعد):
```php
public function create(Request $request): JsonResponse
{
// 1. پیدا کردن دکتر از doctorUuid در body
$doctor = $this->doctorRepo->findByUuid($request->get('doctor_uuid'));
// 2. بررسی سقف پنل
$entityType = 'doctor';
$entityId = $doctor->getId();
$limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId);
$current = $this->secretaryRepo->countActive($doctor->getId());
if ($current >= $limit) {
return $this->error(
ErrorCodes::ERR_SECRETARY_LIMIT_REACHED,
'سقف تعداد منشی پنل اشتراکی شما رسیده است',
Response::HTTP_FORBIDDEN
);
}
// 3. ادامه ایجاد منشی ...
}
```
### خطای جدید که باید در ErrorCodes.php اضافه شود:
```php
// src/Shared/Constant/ErrorCodes.php
const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
// messages: 'سقف تعداد منشی پنل اشتراکی شما رسیده است'
```
## تغییر Frontend — SecretariesPage.tsx
### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
```php
[
'version' => 1,
'resources' => [
'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true],
'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
'clinic_info' => ['view' => true, 'update' => false],
'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false],
],
]
```
### نمونه UI Permissions checkbox matrix در Modal:
```
┌─────────────────────────────────────────────────────┐
│ دسترسی‌های منشی │
├──────────────┬───────┬────────┬────────┬────────────┤
│ بخش │ مشاهده│ ایجاد │ ویرایش │ حذف/لغو │
├──────────────┼───────┼────────┼────────┼────────────┤
│ نوبت‌ها │ ☑ │ ☑ │ ☑ │ ☐ │
│ آدرس‌ها │ ☑ │ ☐ │ ☐ │ ☐ │
│ اطلاعات کلینیک│ ☑ │ - │ ☐ │ - │
│ بیمه‌ها │ ☑ │ ☐ │ ☐ │ ☐ │
└──────────────┴───────┴────────┴────────┴────────────┘
```
### PATCH /api/v1/secretary/{uuid} با permissions:
```json
{
"permissions": {
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"addresses": { "view": true, "create": false, "update": false, "delete": false }
}
}
}
```
## کدهای خطای جدید
| کد | پیام فارسی |
|----|-----------|
| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
## Response خطا (403)
```json
{
"success": false,
"errors": [{
"code": "ERR_SECRETARY_LIMIT_REACHED",
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
}]
}
```
@@ -0,0 +1,70 @@
# جریان کاربری — تسک ۱۲: تکمیل منشی
## جریان افزودن منشی جدید با بررسی سقف پنل
```
دکتر روی «افزودن منشی» کلیک می‌کند → Modal باز می‌شود
POST /api/v1/secretary { phone, ... }
├─► 403 ERR_SECRETARY_LIMIT_REACHED:
│ ┌─────────────────────────────────────────┐
│ │ ⚠️ سقف منشی پنل شما تکمیل شده است │
│ │ پنل Free: حداکثر ۱ منشی │
│ │ │
│ │ [ارتقاء به Basic] [بستن] │
│ └─────────────────────────────────────────┘
└─► 201: منشی ایجاد شد → لیست refresh
```
## جریان ویرایش permissions منشی
```
دکتر روی آیکن تنظیمات کنار منشی کلیک می‌کند
GET /api/v1/secretary/{uuid}
→ بارگذاری permissions فعلی در Modal
┌─────────────────────────────────────────────────────────┐
│ دسترسی‌های علی رضایی │
├──────────────────┬──────────┬────────┬────────┬─────────┤
│ بخش │ مشاهده │ ایجاد │ ویرایش │ لغو/حذف│
├──────────────────┼──────────┼────────┼────────┼─────────┤
│ نوبت‌ها │ ✅ │ ✅ │ ✅ │ ☐ │
│ آدرس‌ها │ ✅ │ ☐ │ ☐ │ ☐ │
│ اطلاعات کلینیک │ ✅ │ - │ ☐ │ - │
│ بیمه‌ها │ ✅ │ ☐ │ ☐ │ ☐ │
└──────────────────┴──────────┴────────┴────────┴─────────┘
│ │
│ [لغو] [ذخیره دسترسی‌ها] │
└─────────────────────────────────────────────────────────┘
PATCH /api/v1/secretary/{uuid}
{ permissions: { version: 1, resources: { ... } } }
200: «دسترسی‌های منشی بروزرسانی شد» → Modal بسته می‌شود
```
## ترتیب سطوح سقف منشی
```
اشتراک → سقف → اگر افزودن فراتر رود
─────────────────────────────────────────
Free → 1 → 403
Basic → 2 → 403
Professional → 5 → 403
```
## نکته: منشی غیرفعال از سقف کم نمی‌شود
```
countActive() فقط منشیان active=true را می‌شمارد
یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی
(مادامی که در پنل Basic هستی و سقف 2 داری)
```
@@ -0,0 +1,130 @@
# معماری — تسک ۱۳: سرویس‌های کلینیک
## ساختار فایل‌ها
```
src/ClinicService/
├── Controller/
│ └── ClinicServiceController.php
├── Entity/
│ ├── ServiceSection.php
│ └── ServiceItem.php
└── Repository/
├── ServiceSectionRepository.php
└── ServiceItemRepository.php
```
## Entity: ServiceSection
```php
#[ORM\Entity(repositoryClass: ServiceSectionRepository::class)]
#[ORM\Table(name: 'service_sections')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_sections_entity')]
class ServiceSection
{
#[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\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Entity: ServiceItem
```php
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
#[ORM\Table(name: 'service_items')]
class ServiceItem
{
#[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: ServiceSection::class)]
#[ORM\JoinColumn(name: 'section_id', nullable: false, onDelete: 'CASCADE')]
private ServiceSection $section;
// staff nullable — پرسنل انجام‌دهنده
#[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Staff\Entity\ClinicStaff $staff = null;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Controller: ClinicServiceController
```php
#[Route('/api/v1')]
class ClinicServiceController extends BaseController
{
// برای همه write actions → gate check اول:
private function assertServicesFeature(): void
{
[$entityType, $entityId] = $this->resolveEntityContext();
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'services')) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
}
#[Route('/service-sections', methods: ['GET'])]
public function listSections(): JsonResponse { ... }
#[Route('/service-section', methods: ['POST'])]
public function createSection(Request $request): JsonResponse
{
$this->assertServicesFeature();
// ...
}
// سایر endpoint ها ...
}
```
## نکته DELETE سرویس با استفاده در پرونده
```php
// اگر session_services.service_item_id → این item باشد:
// FK ON DELETE RESTRICT → Doctrine exception → catch و return 409
try {
$this->em->remove($item);
$this->em->flush();
} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException $e) {
return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, 'این سرویس در پرونده بیمار استفاده شده است', 409);
}
```
@@ -0,0 +1,69 @@
# پایگاه داده — تسک ۱۳: سرویس‌های کلینیک
## جدول: service_sections
| ستون | نوع | توضیح |
|------|-----|-------|
| 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 | |
| name | VARCHAR(200) NOT NULL | نام بخش |
| active | TINYINT(1) NOT NULL DEFAULT 1 | |
| created_at | INT NOT NULL | Unix timestamp |
| updated_at | INT NOT NULL | Unix timestamp |
ایندکس:
```sql
INDEX idx_service_sections_entity ON service_sections(entity_type, entity_id)
```
## جدول: service_items
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| section_id | INT NOT NULL FK→service_sections.id ON DELETE CASCADE | |
| staff_id | INT NULL FK→clinic_staff.id ON DELETE SET NULL | پرسنل انجام‌دهنده (اختیاری) |
| name | VARCHAR(200) NOT NULL | نام سرویس |
| price_rials | INT NOT NULL DEFAULT 0 | |
| active | TINYINT(1) NOT NULL DEFAULT 1 | |
| created_at | INT NOT NULL | |
| updated_at | INT NOT NULL | |
## Migration نمونه
```sql
CREATE TABLE service_sections (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
name VARCHAR(200) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
INDEX idx_service_sections_entity (entity_type, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE service_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
section_id INT NOT NULL,
staff_id INT NULL,
name VARCHAR(200) NOT NULL,
price_rials INT NOT NULL DEFAULT 0,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
FOREIGN KEY (section_id) REFERENCES service_sections(id) ON DELETE CASCADE,
FOREIGN KEY (staff_id) REFERENCES clinic_staff(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## نکات مهم
- `section_id ON DELETE CASCADE` — حذف بخش، همه زیربخش‌ها را هم حذف می‌کند
- `staff_id ON DELETE SET NULL` — غیرفعال‌سازی پرسنل، سرویس را حذف نمی‌کند فقط staff_id=NULL می‌شود
- `session_services.service_item_id` در تسک ۱۵ با `ON DELETE RESTRICT` → اگر item در سشنی باشد، حذف block می‌شود
@@ -0,0 +1,102 @@
# تسک ۱۳: سرویس‌های کلینیک (Clinic Services)
## توضیح
پیاده‌سازی مدیریت سرویس‌های کلینیک در دو سطح: بخش (ServiceSection) و زیربخش (ServiceItem).
دسترسی فقط در پنل **Basic+** — gate: `hasFeature('services')`.
در تسک ۱۵ (پرونده بیمار)، ServiceItems در سشن‌های بیمار استفاده می‌شوند.
## Endpoint ها
| متد | مسیر | Gate | توضیح |
|-----|------|------|-------|
| GET | `/api/v1/service-sections` | Basic+ | لیست بخش‌ها |
| POST | `/api/v1/service-section` | Basic+ | ایجاد بخش |
| PATCH | `/api/v1/service-section/{uuid}` | Basic+ | ویرایش بخش |
| DELETE | `/api/v1/service-section/{uuid}` | Basic+ | حذف بخش |
| GET | `/api/v1/service-items/{sectionUuid}` | Basic+ | لیست زیربخش‌های یک بخش |
| POST | `/api/v1/service-item` | Basic+ | ایجاد زیربخش |
| PATCH | `/api/v1/service-item/{uuid}` | Basic+ | ویرایش زیربخش |
| DELETE | `/api/v1/service-item/{uuid}` | Basic+ | حذف زیربخش |
**نکته DELETE:** حذف سخت مجاز است — اما اگر ServiceItem در session_services استفاده شده باشد، باید با خطا متوقف شود (FK ON DELETE RESTRICT).
## پیش‌نیازها
- **تسک ۱۰** (ClinicStaff — برای staff_id در ServiceItem)
- **تسک ۱۱** (Subscription — hasFeature gate)
## زمان تخمینی
۸ تا ۱۰ ساعت
## نمونه Request
### POST /api/v1/service-section
```json
{
"name": "تزریقات"
}
```
### POST /api/v1/service-item
```json
{
"section_uuid": "uuid-of-section",
"name": "سرم ۵۰۰cc",
"price_rials": 85000,
"staff_uuid": "uuid-of-staff-optional"
}
```
### PATCH /api/v1/service-item/{uuid}
```json
{
"price_rials": 95000,
"staff_uuid": null
}
```
## نمونه Response
### GET /api/v1/service-sections
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "تزریقات",
"active": true,
"items_count": 3
},
{
"uuid": "...",
"name": "فیزیوتراپی",
"active": true,
"items_count": 7
}
]
}
```
### GET /api/v1/service-items/{sectionUuid}
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "سرم ۵۰۰cc",
"price_rials": 85000,
"staff": { "uuid": "...", "full_name": "علی رضایی" },
"active": true
}
]
}
```
## کدهای خطا
| کد | HTTP | توضیح |
|----|------|-------|
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | پنل Basic یا بالاتر لازم است |
| `ERR_SERVICE_SECTION_HAS_ITEMS` | 409 | بخش دارای زیربخش است — ابتدا آنها را حذف کنید |
| `ERR_SERVICE_ITEM_IN_USE` | 409 | سرویس در پرونده بیمار استفاده شده — قابل حذف نیست |
@@ -0,0 +1,99 @@
# جریان کاربری — تسک ۱۳: سرویس‌های کلینیک
## جریان مشاهده و ایجاد سرویس‌ها
```
کاربر وارد صفحه «سرویس‌ها» می‌شود
GET /api/v1/service-sections
→ accordion دو سطحی نمایش داده می‌شود
بر روی هر بخش کلیک می‌کند:
GET /api/v1/service-items/{sectionUuid}
→ زیربخش‌ها نمایش داده می‌شوند
```
## ساختار نمایش (ServicesPage.tsx)
```
┌─────────────────────────────────────────────┐
│ سرویس‌های کلینیک [+ بخش جدید] │
├─────────────────────────────────────────────┤
│ ▼ تزریقات ✏️ 🗑️ │
│ ├─ سرم ۵۰۰cc ۸۵,۰۰۰ ریال ✏️ 🗑️ │
│ ├─ آمپول B12 ۴۵,۰۰۰ ریال ✏️ 🗑️ │
│ └─ [+ سرویس جدید] │
├─────────────────────────────────────────────┤
│ ► فیزیوتراپی ✏️ 🗑️ │
└─────────────────────────────────────────────┘
```
## جریان ایجاد بخش جدید
```
کاربر «+ بخش جدید» کلیک می‌کند → Modal باز می‌شود
POST /api/v1/service-section { name: 'تزریقات' }
├─► 403 ERR_SUBSCRIPTION_REQUIRED → «پنل Basic لازم است»
└─► 201 → accordion به‌روز می‌شود
```
## جریان حذف بخش
```
کاربر روی 🗑️ بخش کلیک می‌کند
ConfirmDialog: «آیا از حذف بخش "تزریقات" مطمئنید؟
همه زیربخش‌های آن هم حذف می‌شوند.»
DELETE /api/v1/service-section/{uuid}
├─► اگر هیچ زیربخشی ندارد → 200 حذف شد
└─► اگر زیربخش دارد → 409 ERR_SERVICE_SECTION_HAS_ITEMS
(یا اگر DELETE CASCADE تعریف شده: همه حذف می‌شوند)
```
## جریان حذف زیربخش
```
DELETE /api/v1/service-item/{uuid}
├─► اگر در هیچ session_services نیست → 200
└─► اگر در session_services استفاده شده → 409 ERR_SERVICE_ITEM_IN_USE
پیام: «این سرویس در پرونده بیمار ثبت شده و قابل حذف نیست.»
```
## جریان انتخاب پرسنل انجام‌دهنده
```
هنگام ایجاد/ویرایش ServiceItem:
GET /api/v1/staff → SearchableSelect از پرسنل فعال
→ «علی رضایی (پرستار)»
→ «مریم نوری (فیزیوتراپیست)»
→ [بدون پرسنل مشخص]
```
## gate check — کاربر Free
```
کاربر Free روی «سرویس‌ها» در sidebar کلیک می‌کند
ServicesPage.tsx → GET /api/v1/service-sections → 403
نمایش banner:
┌─────────────────────────────────────────────┐
│ 🔒 این قابلیت نیاز به پنل Basic یا بالاتر │
│ دارد. │
│ [مشاهده پنل‌ها] │
└─────────────────────────────────────────────┘
```
@@ -0,0 +1,171 @@
# معماری — تسک ۱۴: پنل پیامکی
## ساختار فایل‌ها
```
src/Sms/
├── Controller/
│ ├── SmsController.php ← موجود (تغییر نمی‌کند)
│ └── SmsWalletController.php ← جدید
├── Entity/
│ ├── SmsLog.php ← موجود
│ ├── SmsTemplate.php ← موجود
│ ├── SmsWallet.php ← جدید
│ └── SmsWalletTransaction.php ← جدید
│ └── SmsSettings.php ← جدید
├── Repository/
│ └── SmsWalletRepository.php ← جدید
└── Service/
├── SmsService.php ← موجود — باید کسر wallet اضافه شود
└── SmsWalletService.php ← جدید
```
**فایل‌هایی که تغییر می‌کنند:**
- `src/Payment/Entity/Payment.php` — اضافه کردن `const TYPE_SMS_WALLET = 'sms_wallet'`
- `src/Payment/Controller/PaymentController.php` — callback برای `sms_wallet` type، شارژ wallet
- `src/Sms/Service/SmsService.php` — قبل از ارسال، balance بررسی و کسر شود
## Entity: SmsWallet
```php
#[ORM\Entity]
#[ORM\Table(name: 'sms_wallets')]
#[ORM\UniqueConstraint(name: 'uniq_sms_wallet_entity', columns: ['entity_type', 'entity_id'])]
class SmsWallet
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'integer')]
private int $balanceRials = 0;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Entity: SmsWalletTransaction
```php
#[ORM\Entity]
#[ORM\Table(name: 'sms_wallet_transactions')]
#[ORM\Index(columns: ['sms_wallet_id', 'created_at'], name: 'idx_sms_wallet_tx')]
class SmsWalletTransaction
{
public const TYPE_CREDIT = 'credit';
public const TYPE_DEBIT = 'debit';
#[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: SmsWallet::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private SmsWallet $wallet;
#[ORM\Column(type: 'string', length: 10)]
private string $type; // 'credit' | 'debit'
#[ORM\Column(type: 'integer')]
private int $amountRials;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $description = null;
#[ORM\ManyToOne(targetEntity: \App\Payment\Entity\Payment::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?\App\Payment\Entity\Payment $payment = null;
#[ORM\Column(type: 'integer')]
private int $createdAt;
}
```
## Entity: SmsSettings
```php
#[ORM\Entity]
#[ORM\Table(name: 'sms_settings')]
#[ORM\UniqueConstraint(name: 'uniq_sms_settings_entity', columns: ['entity_type', 'entity_id'])]
class SmsSettings
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'boolean')]
private bool $reminderEnabled = false;
#[ORM\Column(type: 'smallint')]
private int $reminderHoursBefore = 2;
#[ORM\Column(type: 'boolean')]
private bool $postVisitEnabled = false;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $postVisitText = null;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## SmsWalletService
```php
class SmsWalletService
{
public function getOrCreate(string $entityType, int $entityId): SmsWallet
{
// findOneBy([entityType, entityId]) یا ایجاد جدید با balance=0
}
public function charge(SmsWallet $wallet, int $amountRials, Payment $payment): void
{
$wallet->setBalanceRials($wallet->getBalanceRials() + $amountRials);
// ثبت SmsWalletTransaction با type=credit
}
public function deduct(SmsWallet $wallet, int $amountRials, string $description): bool
{
if ($wallet->getBalanceRials() < $amountRials) return false;
$wallet->setBalanceRials($wallet->getBalanceRials() - $amountRials);
// ثبت SmsWalletTransaction با type=debit
return true;
}
}
```
## تغییر SmsService::send()
```php
public function send(string $phone, string $message, string $entityType, int $entityId): bool
{
$priceRials = (int) $this->siteConfigRepo->getValue('sms_price_rials', '0');
$wallet = $this->walletService->getOrCreate($entityType, $entityId);
if (!$this->walletService->deduct($wallet, $priceRials, 'ارسال پیامک')) {
// لاگ: ارسال نشد — موجودی ناکافی
return false;
}
// ارسال از طریق Provider موجود ...
return true;
}
```
@@ -0,0 +1,97 @@
# پایگاه داده — تسک ۱۴: پنل پیامکی
## جدول: sms_wallets
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
| entity_id | INT NOT NULL | |
| balance_rials | INT NOT NULL DEFAULT 0 | موجودی فعلی |
| created_at | INT NOT NULL | |
| updated_at | INT NOT NULL | |
| UNIQUE | (entity_type, entity_id) | یک wallet به ازای هر entity |
## جدول: sms_wallet_transactions
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| sms_wallet_id | INT NOT NULL FK→sms_wallets.id ON DELETE CASCADE | |
| type | VARCHAR(10) NOT NULL | `'credit'` \| `'debit'` |
| amount_rials | INT NOT NULL | مبلغ (همیشه مثبت) |
| description | VARCHAR(255) NULL | توضیح |
| payment_id | INT NULL FK→payments.id ON DELETE SET NULL | برای شارژ |
| created_at | INT NOT NULL | |
ایندکس:
```sql
INDEX idx_sms_wallet_tx ON sms_wallet_transactions(sms_wallet_id, created_at)
```
## جدول: sms_settings
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| entity_type | VARCHAR(10) NOT NULL | |
| entity_id | INT NOT NULL | |
| reminder_enabled | TINYINT(1) NOT NULL DEFAULT 0 | |
| reminder_hours_before | TINYINT NOT NULL DEFAULT 2 | چند ساعت قبل از نوبت |
| post_visit_enabled | TINYINT(1) NOT NULL DEFAULT 0 | |
| post_visit_text | TEXT NULL | متن پیامک بعد از ویزیت |
| updated_at | INT NOT NULL | |
| UNIQUE | (entity_type, entity_id) | |
## SiteConfig key جدید
| کلید | نوع | مقدار پیش‌فرض | توضیح |
|------|-----|--------------|-------|
| `sms_price_rials` | string | `'250'` | قیمت هر پیامک — ادمین تنظیم می‌کند |
## Migration نمونه
```sql
CREATE TABLE sms_wallets (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
balance_rials INT NOT NULL DEFAULT 0,
created_at INT NOT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_sms_wallet_entity (entity_type, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sms_wallet_transactions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
sms_wallet_id INT NOT NULL,
type VARCHAR(10) NOT NULL,
amount_rials INT NOT NULL,
description VARCHAR(255) NULL,
payment_id INT NULL,
created_at INT NOT NULL,
FOREIGN KEY (sms_wallet_id) REFERENCES sms_wallets(id) ON DELETE CASCADE,
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL,
INDEX idx_sms_wallet_tx (sms_wallet_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE sms_settings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
reminder_enabled TINYINT(1) NOT NULL DEFAULT 0,
reminder_hours_before TINYINT NOT NULL DEFAULT 2,
post_visit_enabled TINYINT(1) NOT NULL DEFAULT 0,
post_visit_text TEXT NULL,
updated_at INT NOT NULL,
UNIQUE KEY uniq_sms_settings_entity (entity_type, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## نکات مهم
- `balance_rials` هرگز منفی نمی‌شود — در `SmsWalletService::deduct()` چک می‌شود
- `sms_wallet_transactions` لاگ کامل تمام تراکنش‌ها است — حذف نمی‌شود
- `sms_settings` با UPSERT ذخیره می‌شود (اگر وجود نداشت INSERT، وگرنه UPDATE)
@@ -0,0 +1,127 @@
# تسک ۱۴: پنل پیامکی — کیف پول + تنظیمات (SMS Panel)
## توضیح
زیرساخت SMS موجود است (`src/Sms/` — SmsLog, SmsTemplate, SmsProvider).
این تسک **کیف پول پیامک** اختصاصی و **تنظیمات** ارسال خودکار را اضافه می‌کند.
کیف پول پیامک مستقل از کیف پول مالی (`src/Settlement/`) است.
## Endpoint ها (همه جدید)
| متد | مسیر | Permission | توضیح |
|-----|------|-----------|-------|
| GET | `/api/v1/sms/wallet/balance` | doctor/clinic | موجودی کیف پول پیامک |
| POST | `/api/v1/sms/wallet/charge` | doctor/clinic | شارژ کیف پول |
| GET | `/api/v1/sms/wallet/logs` | doctor/clinic | تاریخچه کسر/شارژ |
| GET | `/api/v1/sms/settings` | doctor/clinic | دریافت تنظیمات |
| PATCH | `/api/v1/sms/settings` | doctor/clinic | ذخیره تنظیمات |
| GET | `/api/v1/admin/sms/wallet-report` | ROLE_ADMIN | گزارش مصرف و درآمد |
## پیش‌نیازها
- تسک ۱۷ (SMS infrastructure — موجود)
- تسک ۱۵-payment (Payment gateway — موجود)
## زمان تخمینی
۱۰ تا ۱۲ ساعت
## نمونه Request
### POST /api/v1/sms/wallet/charge
```json
{
"gateway": "mellat",
"amount_rials": 500000
}
```
→ redirect به gateway (مثل payment نوبت، اما `Payment.type = 'sms_wallet'`)
### PATCH /api/v1/sms/settings
```json
{
"reminder_enabled": true,
"reminder_hours_before": 3,
"post_visit_enabled": false,
"post_visit_text": null
}
```
## نمونه Response
### GET /api/v1/sms/wallet/balance
```json
{
"success": true,
"data": {
"balance_rials": 150000,
"sms_price_rials": 250,
"estimated_sms_count": 600
}
}
```
### GET /api/v1/sms/wallet/logs
```json
{
"success": true,
"data": [
{
"uuid": "...",
"type": "credit",
"amount_rials": 500000,
"description": "شارژ کیف پول پیامک",
"created_at": 1718000000
},
{
"uuid": "...",
"type": "debit",
"amount_rials": 250,
"description": "ارسال پیامک یادآوری — نوبت ۱۴۰۵/۰۳/۱۵",
"created_at": 1718001000
}
],
"meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 }
}
```
### GET /api/v1/sms/settings
```json
{
"success": true,
"data": {
"reminder_enabled": true,
"reminder_hours_before": 3,
"post_visit_enabled": false,
"post_visit_text": null
}
}
```
### GET /api/v1/admin/sms/wallet-report
```json
{
"success": true,
"data": {
"total_charged_rials": 12500000,
"total_deducted_rials": 8750000,
"total_sms_sent": 35000,
"revenue_rials": 8750000,
"by_entity": [
{ "entity_type": "clinic", "entity_id": 5, "name": "کلینیک سلامت", "spent_rials": 1500000 }
]
}
}
```
## SiteConfig key های مرتبط
| کلید | توضیح |
|------|-------|
| `sms_price_rials` | قیمت هر پیامک — ادمین تنظیم می‌کند (مثلاً ۲۵۰ ریال) |
## کسر خودکار هنگام ارسال پیامک
```
هر بار که SmsService::send() صدا زده می‌شود:
1. SmsWallet پیدا شود
2. اگر موجودی کافی نبود → پیامک ارسال نشود + لاگ خطا
3. اگر کافی بود → ارسال + کسر balance_rials + ثبت sms_wallet_transactions
```
@@ -0,0 +1,102 @@
# جریان کاربری — تسک ۱۴: پنل پیامکی
## جریان شارژ کیف پول پیامک
```
کاربر وارد صفحه تنظیمات → تب «پیامک» می‌شود
GET /api/v1/sms/wallet/balance
→ موجودی: ۱۵۰,۰۰۰ ریال (≈ ۶۰۰ پیامک)
کاربر روی «شارژ کیف پول» کلیک می‌کند
فرم: مبلغ شارژ + انتخاب gateway
POST /api/v1/sms/wallet/charge { gateway: 'mellat', amount_rials: 500000 }
PaymentController → ایجاد Payment با type='sms_wallet'
redirect به gateway بانک
callback → SmsWalletService::charge()
→ balance_rials += 500000
→ ثبت credit transaction
redirect به /admin/sms-settings?charged=1
```
## جریان تنظیمات یادآوری خودکار
```
GET /api/v1/sms/settings → فرم پر می‌شود
┌─────────────────────────────────────────────┐
│ تنظیمات پیامک │
├─────────────────────────────────────────────┤
│ یادآوری نوبت │
│ [✅] فعال │
│ چند ساعت قبل: [3 ساعت ▼] │
├─────────────────────────────────────────────┤
│ پیامک بعد از ویزیت │
│ [☐] فعال │
│ متن پیامک: [______________________________]│
└─────────────────────────────────────────────┘
│ [ذخیره تنظیمات] │
PATCH /api/v1/sms/settings
{ reminder_enabled: true, reminder_hours_before: 3,
post_visit_enabled: false, post_visit_text: null }
```
## جریان کسر خودکار هنگام ارسال یادآوری
```
Scheduler/Cronjob اجرا می‌شود (هر ساعت)
نوبت‌هایی که در X ساعت آینده هستند پیدا می‌شوند
برای هر نوبت:
entity_type/entity_id پیدا می‌شود
SmsWallet پیدا می‌شود
├─► balance < sms_price_rials:
│ لاگ خطا: «موجودی ناکافی» — پیامک ارسال نشد
└─► balance کافی:
SmsService::send(phone, message, entityType, entityId)
→ ارسال پیامک
→ deduct balance
→ ثبت debit transaction با description='یادآوری نوبت'
```
## نمایش در Frontend
```
┌─────────────────────────────────────────────────┐
│ تنظیمات پیامک │
├──────────────────────┬──────────────────────────┤
│ کیف پول پیامک │ تنظیمات ارسال │
│ │ │
│ موجودی: │ یادآوری: ✅ ۳ ساعت قبل │
│ ۱۵۰,۰۰۰ ریال │ │
│ ≈ ۶۰۰ پیامک │ پس از ویزیت: ☐ غیرفعال │
│ │ │
│ [شارژ کیف پول] │ [ذخیره تنظیمات] │
├──────────────────────┴──────────────────────────┤
│ تاریخچه تراکنش‌ها │
│ ✅ +۵۰۰,۰۰۰ ریال — شارژ — ۱۴۰۵/۰۳/۱۰ │
│ ⬇️ -۲۵۰ ریال — یادآوری نوبت — ۱۴۰۵/۰۳/۱۱ │
└─────────────────────────────────────────────────┘
```
@@ -0,0 +1,204 @@
# معماری — تسک ۱۵: پرونده بیمار
## ساختار فایل‌ها
```
src/Patient/
├── Controller/
│ └── PatientController.php
├── Entity/
│ ├── PatientRecord.php
│ ├── PatientSession.php
│ └── SessionService.php ← entity (سرویس‌های انجام‌شده در سشن)
├── Repository/
│ ├── PatientRecordRepository.php
│ └── PatientSessionRepository.php
└── Service/
└── PatientService.php
```
**فایل‌هایی که تغییر می‌کنند:**
- `src/Appointment/Controller/AppointmentController.php` — متد `updateStatus()` باید `PatientService::autoCreateOnAppointmentConfirm()` صدا بزند
## Entity: PatientRecord
```php
#[ORM\Entity]
#[ORM\Table(name: 'patient_records')]
#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_patient_records_entity')]
class PatientRecord
{
#[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: \App\Auth\Entity\User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: false, onDelete: 'RESTRICT')]
private \App\Auth\Entity\User $user;
// چه کسی پرونده را باز کرد
#[ORM\Column(type: 'string', length: 15)]
private string $createdByType; // 'doctor' | 'secretary' | 'system'
#[ORM\Column(type: 'integer')]
private int $createdById;
#[ORM\Column(type: 'integer')]
private int $createdAt;
}
```
## Entity: PatientSession
```php
#[ORM\Entity]
#[ORM\Table(name: 'patient_sessions')]
#[ORM\Index(columns: ['record_id', 'created_at'], name: 'idx_patient_sessions_record')]
class PatientSession
{
#[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: PatientRecord::class)]
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
private PatientRecord $record;
#[ORM\ManyToOne(targetEntity: \App\Appointment\Entity\Appointment::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?\App\Appointment\Entity\Appointment $appointment = null;
// بیمه پایه — FK به categories bundle='insurance_type'
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $insuranceSupplementaryId = null;
#[ORM\Column(type: 'integer')]
private int $visitPriceRials = 0;
#[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
private float $baseInsuranceDiscountPercent = 0;
#[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
private float $supplementaryDiscountPercent = 0;
#[ORM\Column(type: 'integer')]
private int $servicesTotalRials = 0;
#[ORM\Column(type: 'integer')]
private int $finalPriceRials = 0;
#[ORM\Column(type: 'string', length: 15)]
private string $paymentMethod = 'pending'; // 'cash'|'card'|'insurance'|'pending'
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notes = null;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Entity: SessionService (سرویس‌های انجام‌شده در سشن)
```php
#[ORM\Entity]
#[ORM\Table(name: 'session_services')]
class SessionService
{
#[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: PatientSession::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private PatientSession $session;
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
private \App\ClinicService\Entity\ServiceItem $serviceItem;
#[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Staff\Entity\ClinicStaff $staff = null;
// کپی قیمت در زمان ثبت — تغییر قیمت سرویس بعداً اثر ندارد
#[ORM\Column(type: 'integer')]
private int $priceRials;
#[ORM\Column(type: 'integer')]
private int $createdAt;
}
```
## PatientService
```php
class PatientService
{
public function calculateFinalPrice(
int $visitPrice,
float $baseDiscount,
float $suppDiscount,
array $serviceItems // [{price_rials}]
): array {
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
$servicesTotal = array_sum(array_column($serviceItems, 'price_rials'));
$finalPrice = (int) round($afterSupp) + $servicesTotal;
return [
'services_total_rials' => $servicesTotal,
'final_price_rials' => $finalPrice,
];
}
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
[$entityType, $entityId] = $this->resolveEntityFromAppointment($appointment);
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return; // پنل Free — ایجاد نشود
}
$userId = $appointment->getUser()->getId();
$record = $this->recordRepo->findOneBy([
'entityType' => $entityType,
'entityId' => $entityId,
'user' => $userId,
]) ?? $this->createRecord($entityType, $entityId, $userId, 'system');
// سشن با appointment_id، visit_price=0 (تکمیل بعداً)
$this->createSession($record, $appointment);
}
}
```
## تغییر در AppointmentController
```php
// src/Appointment/Controller/AppointmentController.php
// متد updateStatus() — بعد از ذخیره status جدید:
if ($newStatus === 'confirmed') {
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
}
```
@@ -0,0 +1,116 @@
# پایگاه داده — تسک ۱۵: پرونده بیمار
## جدول: patient_records
| ستون | نوع | توضیح |
|------|-----|-------|
| 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 | |
| user_id | INT NOT NULL FK→users.id ON DELETE RESTRICT | بیمار |
| created_by_type | VARCHAR(15) NOT NULL | `'doctor'` \| `'secretary'` \| `'system'` |
| created_by_id | INT NOT NULL | id ایجادکننده |
| created_at | INT NOT NULL | |
```sql
UNIQUE KEY uniq_patient_record (entity_type, entity_id, user_id)
INDEX idx_patient_records_entity (entity_type, entity_id)
```
## جدول: patient_sessions
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| record_id | INT NOT NULL FK→patient_records.id ON DELETE CASCADE | |
| appointment_id | INT NULL FK→appointments.id ON DELETE SET NULL | |
| insurance_base_id | INT NULL FK→categories.id ON DELETE SET NULL | بیمه پایه |
| insurance_supplementary_id | INT NULL FK→categories.id ON DELETE SET NULL | بیمه مکمل |
| visit_price_rials | INT NOT NULL DEFAULT 0 | |
| base_insurance_discount_percent | DECIMAL(5,2) NOT NULL DEFAULT 0 | |
| supplementary_discount_percent | DECIMAL(5,2) NOT NULL DEFAULT 0 | |
| services_total_rials | INT NOT NULL DEFAULT 0 | کپی محاسبه‌شده |
| final_price_rials | INT NOT NULL DEFAULT 0 | |
| payment_method | VARCHAR(15) NOT NULL DEFAULT 'pending' | `'cash'`\|`'card'`\|`'insurance'`\|`'pending'` |
| notes | TEXT NULL | |
| created_at | INT NOT NULL | |
| updated_at | INT NOT NULL | |
```sql
INDEX idx_patient_sessions_record (record_id, created_at)
```
## جدول: session_services
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| session_id | INT NOT NULL FK→patient_sessions.id ON DELETE CASCADE | |
| service_item_id | INT NOT NULL FK→service_items.id ON DELETE RESTRICT | |
| staff_id | INT NULL FK→clinic_staff.id ON DELETE SET NULL | |
| price_rials | INT NOT NULL | **کپی** قیمت در زمان ثبت |
| created_at | INT NOT NULL | |
## Migration نمونه
```sql
CREATE TABLE patient_records (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
user_id INT NOT NULL,
created_by_type VARCHAR(15) NOT NULL,
created_by_id INT NOT NULL,
created_at INT NOT NULL,
UNIQUE KEY uniq_patient_record (entity_type, entity_id, user_id),
INDEX idx_patient_records_entity (entity_type, entity_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE patient_sessions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
record_id INT NOT NULL,
appointment_id INT NULL,
insurance_base_id INT NULL,
insurance_supplementary_id INT NULL,
visit_price_rials INT NOT NULL DEFAULT 0,
base_insurance_discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
supplementary_discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
services_total_rials INT NOT NULL DEFAULT 0,
final_price_rials INT NOT NULL DEFAULT 0,
payment_method VARCHAR(15) NOT NULL DEFAULT 'pending',
notes TEXT NULL,
created_at INT NOT NULL,
updated_at INT NOT NULL,
FOREIGN KEY (record_id) REFERENCES patient_records(id) ON DELETE CASCADE,
FOREIGN KEY (appointment_id) REFERENCES appointments(id) ON DELETE SET NULL,
FOREIGN KEY (insurance_base_id) REFERENCES categories(id) ON DELETE SET NULL,
FOREIGN KEY (insurance_supplementary_id) REFERENCES categories(id) ON DELETE SET NULL,
INDEX idx_patient_sessions_record (record_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE session_services (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
session_id INT NOT NULL,
service_item_id INT NOT NULL,
staff_id INT NULL,
price_rials INT NOT NULL,
created_at INT NOT NULL,
FOREIGN KEY (session_id) REFERENCES patient_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (service_item_id) REFERENCES service_items(id) ON DELETE RESTRICT,
FOREIGN KEY (staff_id) REFERENCES clinic_staff(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## نکات مهم
- **UNIQUE(entity_type, entity_id, user_id)** — یک بیمار یک پرونده در هر کلینیک/مطب دارد
- **price_rials در session_services کپی می‌شود** — تغییر قیمت سرویس در آینده روی سشن‌های قبلی اثر ندارد
- **services_total_rials و final_price_rials** در PatientSession کپی محاسبه‌شده هستند — برای گزارش‌گیری سریع
- **user_id ON DELETE RESTRICT** — نمی‌توان کاربری را که پرونده دارد حذف کرد
@@ -0,0 +1,155 @@
# تسک ۱۵: پرونده بیمار (Patient Records)
## توضیح
پیاده‌سازی سیستم پرونده الکترونیک بیمار. هر بیمار یک پرونده در هر کلینیک/مطب دارد.
هر مراجعه یک سشن است که شامل اطلاعات بیمه، مبلغ ویزیت و سرویس‌های انجام‌شده است.
دسترسی فقط در پنل **Basic+** — gate: `hasFeature('patient_records')`.
## Endpoint ها
| متد | مسیر | Gate | توضیح |
|-----|------|------|-------|
| GET | `/api/v1/patients` | Basic+ | لیست بیماران — `?search=name/phone` |
| POST | `/api/v1/patient` | Basic+ | ایجاد پرونده دستی |
| GET | `/api/v1/patient/{uuid}` | Basic+ | جزئیات پرونده |
| GET | `/api/v1/patient/{uuid}/sessions` | Basic+ | لیست مراجعات |
| POST | `/api/v1/patient/{uuid}/session` | Basic+ | ثبت مراجعه جدید |
| PATCH | `/api/v1/session/{uuid}` | Basic+ | ویرایش مراجعه |
## پیش‌نیازها
- **تسک ۱۰** (ClinicStaff — برای staff_id در SessionService)
- **تسک ۱۱** (Subscription — gate check)
- **تسک ۱۳** (ClinicService — ServiceItem در session)
## زمان تخمینی
۱۶ تا ۲۰ ساعت
## نمونه Request
### POST /api/v1/patient (ایجاد دستی)
```json
{
"user_uuid": "uuid-of-user"
}
```
### POST /api/v1/patient/{uuid}/session
```json
{
"appointment_uuid": null,
"insurance_base_uuid": "category-uuid-insurance-type",
"insurance_supplementary_uuid": null,
"visit_price_rials": 500000,
"base_insurance_discount_percent": 30,
"supplementary_discount_percent": 0,
"payment_method": "cash",
"notes": "بیمار شکایت از کمردرد داشت",
"services": [
{ "service_item_uuid": "uuid-of-service-item", "staff_uuid": "uuid-of-staff" },
{ "service_item_uuid": "uuid-of-another-item", "staff_uuid": null }
]
}
```
### PATCH /api/v1/session/{uuid}
```json
{
"notes": "ویرایش یادداشت",
"payment_method": "card"
}
```
## فرمول محاسبه final_price
```
after_base = visit_price_rials × (1 - base_insurance_discount_percent / 100)
after_supp = after_base × (1 - supplementary_discount_percent / 100)
services_total = Σ service_item.price_rials (قیمت در زمان ثبت — نه قیمت فعلی)
final_price = round(after_supp) + services_total
```
مثال عددی:
```
visit_price = 500,000
base_disc = 30% → after_base = 350,000
supp_disc = 10% → after_supp = 315,000
services = [85,000 + 45,000] = 130,000
final = 315,000 + 130,000 = 445,000
```
## نمونه Response
### GET /api/v1/patients?search=علی
```json
{
"success": true,
"data": [
{
"uuid": "...",
"user": { "uuid": "...", "name": "علی محمدی", "phone": "09121234567" },
"sessions_count": 5,
"last_session_at": 1718000000,
"created_at": 1710000000
}
],
"meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 }
}
```
### GET /api/v1/patient/{uuid}
```json
{
"success": true,
"data": {
"uuid": "...",
"user": { "uuid": "...", "name": "علی محمدی", "phone": "09121234567" },
"sessions_count": 5,
"total_paid_rials": 2250000,
"created_at": 1710000000
}
}
```
### GET /api/v1/patient/{uuid}/sessions
```json
{
"success": true,
"data": [
{
"uuid": "...",
"appointment_uuid": null,
"visit_price_rials": 500000,
"base_insurance_discount_percent": 30,
"services_total_rials": 130000,
"final_price_rials": 445000,
"payment_method": "cash",
"notes": "...",
"services": [
{ "name": "سرم ۵۰۰cc", "price_rials": 85000, "staff_name": "علی رضایی" }
],
"created_at": 1718000000
}
],
"meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
}
```
## ایجاد خودکار پرونده هنگام تأیید نوبت
```
AppointmentController::updateStatus(uuid, 'confirmed')
→ اگر entityType/entityId پنل Basic+ دارد:
→ PatientService::autoCreateOnAppointmentConfirm(appointment)
→ PatientRecord.findOrCreate(entityType, entityId, userId)
→ PatientSession جدید با appointment_id و visit_price پیش‌فرض (0)
→ کاربر بعداً در پرونده می‌تواند اطلاعات بیمه و قیمت را تکمیل کند
```
## کدهای خطا
| کد | HTTP | توضیح |
|----|------|-------|
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | Basic+ لازم است |
| `ERR_PATIENT_NOT_FOUND` | 404 | پرونده پیدا نشد |
| `ERR_SESSION_NOT_FOUND` | 404 | مراجعه پیدا نشد |
| `ERR_SERVICE_ITEM_NOT_FOUND` | 404 | زیربخش سرویس پیدا نشد |
@@ -0,0 +1,123 @@
# جریان کاربری — تسک ۱۵: پرونده بیمار
## جریان خودکار — تأیید نوبت → ایجاد پرونده
```
دکتر/منشی نوبت را تأیید می‌کند
PATCH /api/v1/appointment/{uuid}/status { status: 'confirmed' }
AppointmentController::updateStatus()
آیا entity دارای پنل Basic+ است؟
خیر → پرونده ایجاد نمی‌شود (Free plan)
بله ↓
PatientService::autoCreateOnAppointmentConfirm()
آیا PatientRecord برای این (entity, user) وجود دارد؟
خیر → ایجاد PatientRecord جدید (created_by='system')
بله → همان پرونده استفاده می‌شود
ایجاد PatientSession با:
appointment_id = appointment.id
visit_price_rials = 0 ← کاربر بعداً تکمیل می‌کند
payment_method = 'pending'
```
## جریان دستی — ایجاد پرونده توسط منشی
```
منشی در صفحه «بیماران» کلیک می‌کند → [+ بیمار جدید]
Modal: جستجو بیمار با نام یا شماره تلفن
GET /api/v1/admin/users?search=09121234567
بیمار انتخاب می‌شود
POST /api/v1/patient { user_uuid: '...' }
├─► 409: این بیمار قبلاً پرونده دارد → نمایش پرونده موجود
└─► 201: پرونده ایجاد شد → redirect به PatientDetailPage
```
## جریان ثبت مراجعه (سشن)
```
منشی/دکتر در صفحه پرونده بیمار، روی «+ مراجعه جدید» کلیک می‌کند
Modal ثبت سشن:
┌──────────────────────────────────────────────────────┐
│ ثبت مراجعه جدید │
├──────────────────────────────────────────────────────┤
│ بیمه پایه: [تأمین اجتماعی ▼] │
│ درصد کسر: [۳۰%____________] │
│ بیمه مکمل: [---بدون مکمل--- ▼] │
├──────────────────────────────────────────────────────┤
│ مبلغ ویزیت: [500,000 ریال____] │
├──────────────────────────────────────────────────────┤
│ سرویس‌های انجام‌شده: │
│ [+ سرم ۵۰۰cc × علی رضایی] [+ سرویس دیگر] │
├──────────────────────────────────────────────────────┤
│ 📊 محاسبه: │
│ ویزیت: 500,000 → بعد از بیمه: 315,000 │
│ سرویس‌ها: 130,000 │
│ مجموع: 445,000 ریال │
├──────────────────────────────────────────────────────┤
│ روش پرداخت: [نقدی ▼] │
│ یادداشت: [____________________________] │
└──────────────────────────────────────────────────────┘
│ [لغو] [ثبت مراجعه] │
POST /api/v1/patient/{uuid}/session { ... }
سشن ثبت شد → لیست مراجعات refresh می‌شود
```
## محاسبه real-time در Frontend
```
هر بار که visit_price یا discount درصد تغییر می‌کند:
after_base = visit_price × (1 - base_discount / 100)
after_supp = after_base × (1 - supp_discount / 100)
final = round(after_supp) + services_total
نمایش: «مبلغ قابل پرداخت: ۴۴۵,۰۰۰ ریال»
```
## جریان جستجو بیمار
```
GET /api/v1/patients?search=علی&page=1
→ جستجو در نام و شماره تلفن user
→ نمایش paginated در DataTable
→ کلیک روی هر ردیف → PatientDetailPage
```
## نمایش صفحه بیمار (PatientDetailPage.tsx)
```
┌─────────────────────────────────────────────────────┐
│ علی محمدی — 09121234567 │
│ ۵ مراجعه | آخرین: ۱۴۰۵/۰۳/۱۵ | مجموع: ۲,۲۵۰,۰۰۰ │
├─────────────────────────────────────────────────────┤
│ [+ مراجعه جدید] │
├──────────────┬──────────────┬──────────────────────┤
│ تاریخ │ مبلغ نهایی │ روش پرداخت عملیات │
├──────────────┼──────────────┼──────────────────────┤
│ ۱۴۰۵/۰۳/۱۵ │ ۴۴۵,۰۰۰ ریال│ نقدی ✏️ 👁️ │
│ ۱۴۰۵/۰۲/۰۸ │ ۳۲۰,۰۰۰ ریال│ کارت ✏️ 👁️ │
└──────────────┴──────────────┴──────────────────────┘
```
@@ -0,0 +1,100 @@
# معماری — تسک ۱۶: داشبورد هوشمند
## فایل‌هایی که تغییر می‌کنند
```
src/Dashboard/Controller/DashboardController.php ← اضافه کردن from/to به query + فیلدهای جدید
src/Admin/Controller/AdminApiController.php ← متد dashboardCharts() → محدود به بازه زمانی
assets/admin/pages/DashboardPage.tsx ← date range selector + نمودارها
```
## تغییر Backend
### DashboardController — اضافه کردن from/to
```php
#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
public function clinic(Request $request): JsonResponse
{
$from = (int) $request->query->get('from', strtotime('-30 days'));
$to = (int) $request->query->get('to', time());
// query موجود را محدود به بازه می‌کنیم:
// WHERE created_at BETWEEN :from AND :to
// فیلدهای جدید:
$smsBalance = $this->smsWalletRepo->getBalance($entityType, $entityId);
$uniquePatients = $this->patientRecordRepo->countUnique($entityType, $entityId, $from, $to);
$revenue = $this->patientSessionRepo->sumRevenue($entityType, $entityId, $from, $to);
return $this->success([
// ... فیلدهای موجود ...
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'revenue_period_rials' => $revenue,
]);
}
```
### AdminApiController — dashboardCharts با بازه زمانی
```php
#[Route('/api/v1/admin/dashboard/charts', methods: ['GET'])]
public function dashboardCharts(Request $request): JsonResponse
{
$from = (int) $request->query->get('from', strtotime('-30 days'));
$to = (int) $request->query->get('to', time());
// appointments_by_day: GROUP BY DATE(FROM_UNIXTIME(created_at))
// revenue_by_day: از patient_sessions در بازه
// subscription_sales_by_plan: از clinic_subscriptions در بازه
}
```
## تغییر Frontend — DashboardPage.tsx
### date range selector component:
```tsx
type DateRangePreset = 'week' | 'month' | '3months' | 'custom';
interface DateRange {
from: number; // Unix timestamp
to: number;
}
function DateRangeSelector({ value, onChange }: {
value: DateRange;
onChange: (r: DateRange) => void;
}) {
// preset buttons + PersianCalendar برای custom range
}
```
### نمودارها با Recharts:
```tsx
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
// chart نوبت‌ها
<ResponsiveContainer width="100%" height={250}>
<LineChart data={chartsData?.appointments_by_day}>
<XAxis dataKey="date" tickFormatter={d => formatDate(d)} />
<YAxis />
<Tooltip />
<Line dataKey="count" stroke="#3b82f6" />
</LineChart>
</ResponsiveContainer>
// chart درآمد
<BarChart data={chartsData?.revenue_by_day}>
<Bar dataKey="amount_rials" fill="#10b981" />
</BarChart>
```
### نصب dependency:
```bash
ddev exec yarn add recharts
ddev exec yarn add @types/recharts # اگر نیاز بود
```
@@ -0,0 +1,63 @@
# پایگاه داده — تسک ۱۶: داشبورد هوشمند
## هیچ migration لازم نیست
همه داده‌ها از جداول موجود و جداول ساخته‌شده در تسک‌های قبل خوانده می‌شوند.
## Query های جدید
### نوبت‌ها بر اساس روز (admin chart)
```sql
SELECT
FLOOR(created_at / 86400) * 86400 AS date_unix,
COUNT(*) AS count
FROM appointments
WHERE created_at BETWEEN :from AND :to
GROUP BY date_unix
ORDER BY date_unix ASC
```
### درآمد بر اساس روز (از patient_sessions)
```sql
SELECT
FLOOR(created_at / 86400) * 86400 AS date_unix,
SUM(final_price_rials) AS amount_rials
FROM patient_sessions
WHERE entity_type = :entityType
AND record_id IN (
SELECT id FROM patient_records WHERE entity_type = :entityType AND entity_id = :entityId
)
AND created_at BETWEEN :from AND :to
GROUP BY date_unix
ORDER BY date_unix ASC
```
### فروش اشتراک بر اساس پنل (admin)
```sql
SELECT
sp.name AS plan,
COUNT(cs.id) AS count,
SUM(p.amount) AS total_rials
FROM clinic_subscriptions cs
JOIN subscription_plans sp ON cs.plan_id = sp.id
LEFT JOIN payments p ON cs.payment_id = p.id
WHERE cs.is_trial = 0
AND cs.created_at BETWEEN :from AND :to
GROUP BY sp.name
```
### بیماران منحصربه‌فرد در بازه
```sql
SELECT COUNT(DISTINCT pr.user_id) AS unique_count
FROM patient_records pr
JOIN patient_sessions ps ON ps.record_id = pr.id
WHERE pr.entity_type = :entityType
AND pr.entity_id = :entityId
AND ps.created_at BETWEEN :from AND :to
```
## نکات مهم
- تمام `created_at` ها Unix timestamp هستند — فیلتر بازه زمانی مستقیم روی عدد اعمال می‌شود
- گروه‌بندی روزانه: `FLOOR(created_at / 86400) * 86400` → شروع روز به Unix
- نمایش در frontend: تبدیل Unix به تاریخ شمسی با `formatDate()`
@@ -0,0 +1,78 @@
# تسک ۱۶: داشبورد هوشمند — چارت + فیلتر زمانی
## توضیح
داشبوردهای موجود را با نمودار و فیلتر بازه زمانی تکمیل می‌کند.
**هیچ endpoint جدیدی ایجاد نمی‌شود** — فقط پارامتر `from` و `to` (Unix timestamp) به endpoint های موجود اضافه می‌شود.
همچنین چند فیلد جدید به response های موجود اضافه می‌شود.
## Endpoint های موجود که تغییر می‌کنند
| متد | مسیر | تغییر |
|-----|------|-------|
| GET | `/api/v1/admin/dashboard/charts` | اضافه: query params `from` و `to` |
| GET | `/api/v1/dashboard/clinic` | اضافه: `from`، `to` + فیلدهای جدید response |
| GET | `/api/v1/dashboard/doctor` | اضافه: `from`، `to` + فیلدهای جدید response |
## فیلدهای جدید در Response
### GET /api/v1/dashboard/clinic
فیلدهای اضافه‌شده به data موجود:
```json
{
"sms_wallet_balance": 150000,
"unique_patients_count": 45,
"revenue_period_rials": 12500000
}
```
### GET /api/v1/dashboard/doctor
فیلدهای اضافه‌شده:
```json
{
"unique_patients_count": 28,
"revenue_period_rials": 7800000
}
```
### GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX
```json
{
"success": true,
"data": {
"appointments_by_day": [
{ "date": 1718000000, "count": 12 },
{ "date": 1718086400, "count": 8 }
],
"revenue_by_day": [
{ "date": 1718000000, "amount_rials": 4500000 },
{ "date": 1718086400, "amount_rials": 3200000 }
],
"subscription_sales_by_plan": [
{ "plan": "basic", "count": 15, "total_rials": 3750000 },
{ "plan": "professional", "count": 5, "total_rials": 2500000 }
]
}
}
```
## پیش‌نیازها
- همه تسک‌های ۱۰–۱۵ (نیاز به داده واقعی)
- `yarn add recharts` برای نمودارها
## زمان تخمینی
۸ تا ۱۰ ساعت
## فیلتر بازه زمانی
| preset | محاسبه |
|--------|--------|
| این هفته | from = شروع هفته جاری (شنبه) — to = now |
| این ماه | from = اول ماه جاری (شمسی) — to = now |
| ۳ ماه | from = now - 90 روز — to = now |
| سفارشی | کاربر از PersianCalendar انتخاب می‌کند |
**تبدیل تاریخ شمسی به Unix timestamp** برای ارسال به API:
```ts
// از کتابخانه موجود در پروژه استفاده می‌شود
// PersianCalendar component از assets/admin/components/ui/
```
@@ -0,0 +1,98 @@
# جریان کاربری — تسک ۱۶: داشبورد هوشمند
## جریان انتخاب بازه زمانی
```
کاربر وارد صفحه داشبورد می‌شود
بازه پیش‌فرض: «این ماه»
from = اول ماه جاری (شمسی → Unix)
to = now
GET /api/v1/dashboard/clinic?from=UNIX&to=UNIX
کاربر روی preset کلیک می‌کند:
[این هفته] [این ماه] [۳ ماه] [سفارشی]
انتخاب «سفارشی»:
PersianCalendar range picker باز می‌شود
کاربر از/تا را انتخاب می‌کند
URL update: ?from=UNIX&to=UNIX
TanStack Query refetch می‌شود
```
## جریان نمایش داشبورد کلینیک/مطب
```
┌──────────────────────────────────────────────────────────────┐
│ داشبورد کلینیک │
│ [این هفته] [این ماه ★] [۳ ماه] [سفارشی] │
├──────────┬──────────┬──────────┬──────────────────────────── │
│ نوبت‌های │ بیماران │ درآمد │ موجودی پیامک │
│ امروز: ۸ │ منحصربه │ این ماه │ ۱۵۰,۰۰۰ ریال │
│ │ فرد: ۴۵ │ ۱۲.۵M │ │
├──────────┴──────────┴──────────┴──────────────────────────── │
│ نمودار نوبت‌ها — این ماه │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ▄ ▄ ▄ ▄ │ │
│ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │
│ │ ────────────────────────────────────── روز │ │
│ └────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────── │
│ نمودار درآمد — این ماه │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ █ █ █ █ │ │
│ │ █ █ █ █ █ █ █ █ █ │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
## جریان داشبورد ادمین
```
GET /api/v1/admin/dashboard/stats → کارت‌های آمار کلی
GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → نمودارها
نمودار اضافه: «فروش اشتراک بر اساس پنل»
basic: ۱۵ فروش | professional: ۵ فروش
(Bar chart با رنگ‌بندی متفاوت برای هر plan)
```
## فیلتر نقش — منشی
```
منشی وارد داشبورد می‌شود
→ فقط آمار نوبت‌ها نمایش داده می‌شود
→ فیلد درآمد: مخفی
→ فیلد بیماران: مخفی (اگر پنل Basic+ نباشد)
→ موجودی پیامک: مخفی
```
## preset ها — محاسبه از/تا
```typescript
function getDateRange(preset: DateRangePreset): DateRange {
const now = Math.floor(Date.now() / 1000);
switch (preset) {
case 'week':
// شنبه این هفته
const dayOfWeek = new Date().getDay(); // 0=یکشنبه ... 6=شنبه
const daysToSaturday = dayOfWeek === 6 ? 0 : dayOfWeek + 1;
return { from: now - daysToSaturday * 86400, to: now };
case 'month':
// اول ماه جاری شمسی → Unix timestamp
// از PersianCalendar helper استفاده می‌شود
return { from: startOfCurrentPersianMonth(), to: now };
case '3months':
return { from: now - 90 * 86400, to: now };
}
}
```