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:
@@ -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;
|
||||
```
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user