From b58aacc37f97cb328e93fd447601925cc1bdb2b9 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 14 Jun 2026 21:15:40 +0330 Subject: [PATCH] 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. --- .../task-10-staff/architecture.md | 172 +++++++++++++++ docs/phase2_taskes/task-10-staff/database.md | 51 +++++ docs/phase2_taskes/task-10-staff/task.md | 107 +++++++++ docs/phase2_taskes/task-10-staff/user_flow.md | 86 ++++++++ .../task-11-subscription/architecture.md | 207 ++++++++++++++++++ .../task-11-subscription/database.md | 92 ++++++++ .../task-11-subscription/task.md | 160 ++++++++++++++ .../task-11-subscription/user_flow.md | 126 +++++++++++ .../architecture.md | 71 ++++++ .../task-12-secretary-completion/database.md | 59 +++++ .../task-12-secretary-completion/task.md | 134 ++++++++++++ .../task-12-secretary-completion/user_flow.md | 70 ++++++ .../task-13-clinic-services/architecture.md | 130 +++++++++++ .../task-13-clinic-services/database.md | 69 ++++++ .../task-13-clinic-services/task.md | 102 +++++++++ .../task-13-clinic-services/user_flow.md | 99 +++++++++ .../task-14-sms-panel/architecture.md | 171 +++++++++++++++ .../task-14-sms-panel/database.md | 97 ++++++++ docs/phase2_taskes/task-14-sms-panel/task.md | 127 +++++++++++ .../task-14-sms-panel/user_flow.md | 102 +++++++++ .../task-15-patient-records/architecture.md | 204 +++++++++++++++++ .../task-15-patient-records/database.md | 116 ++++++++++ .../task-15-patient-records/task.md | 155 +++++++++++++ .../task-15-patient-records/user_flow.md | 123 +++++++++++ .../task-16-smart-dashboard/architecture.md | 100 +++++++++ .../task-16-smart-dashboard/database.md | 63 ++++++ .../task-16-smart-dashboard/task.md | 78 +++++++ .../task-16-smart-dashboard/user_flow.md | 98 +++++++++ 28 files changed, 3169 insertions(+) create mode 100644 docs/phase2_taskes/task-10-staff/architecture.md create mode 100644 docs/phase2_taskes/task-10-staff/database.md create mode 100644 docs/phase2_taskes/task-10-staff/task.md create mode 100644 docs/phase2_taskes/task-10-staff/user_flow.md create mode 100644 docs/phase2_taskes/task-11-subscription/architecture.md create mode 100644 docs/phase2_taskes/task-11-subscription/database.md create mode 100644 docs/phase2_taskes/task-11-subscription/task.md create mode 100644 docs/phase2_taskes/task-11-subscription/user_flow.md create mode 100644 docs/phase2_taskes/task-12-secretary-completion/architecture.md create mode 100644 docs/phase2_taskes/task-12-secretary-completion/database.md create mode 100644 docs/phase2_taskes/task-12-secretary-completion/task.md create mode 100644 docs/phase2_taskes/task-12-secretary-completion/user_flow.md create mode 100644 docs/phase2_taskes/task-13-clinic-services/architecture.md create mode 100644 docs/phase2_taskes/task-13-clinic-services/database.md create mode 100644 docs/phase2_taskes/task-13-clinic-services/task.md create mode 100644 docs/phase2_taskes/task-13-clinic-services/user_flow.md create mode 100644 docs/phase2_taskes/task-14-sms-panel/architecture.md create mode 100644 docs/phase2_taskes/task-14-sms-panel/database.md create mode 100644 docs/phase2_taskes/task-14-sms-panel/task.md create mode 100644 docs/phase2_taskes/task-14-sms-panel/user_flow.md create mode 100644 docs/phase2_taskes/task-15-patient-records/architecture.md create mode 100644 docs/phase2_taskes/task-15-patient-records/database.md create mode 100644 docs/phase2_taskes/task-15-patient-records/task.md create mode 100644 docs/phase2_taskes/task-15-patient-records/user_flow.md create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/architecture.md create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/database.md create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/task.md create mode 100644 docs/phase2_taskes/task-16-smart-dashboard/user_flow.md diff --git a/docs/phase2_taskes/task-10-staff/architecture.md b/docs/phase2_taskes/task-10-staff/architecture.md new file mode 100644 index 00000000..812d37c9 --- /dev/null +++ b/docs/phase2_taskes/task-10-staff/architecture.md @@ -0,0 +1,172 @@ +# معماری — تسک ۱۰: مدیریت پرسنل (Staff) + +## ساختار فایل‌ها + +``` +src/Staff/ +├── Controller/ +│ └── StaffController.php +├── Entity/ +│ └── ClinicStaff.php +├── Repository/ +│ └── ClinicStaffRepository.php +└── Service/ + └── StaffService.php +``` + +## Entity: ClinicStaff + +```php +uuid = (string) Uuid::v4(); + $this->createdAt = time(); + $this->updatedAt = time(); + } + + // getters/setters ... +} +``` + +## Controller: StaffController + +```php +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 + $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` diff --git a/docs/phase2_taskes/task-10-staff/database.md b/docs/phase2_taskes/task-10-staff/database.md new file mode 100644 index 00000000..d7ebd307 --- /dev/null +++ b/docs/phase2_taskes/task-10-staff/database.md @@ -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; +``` diff --git a/docs/phase2_taskes/task-10-staff/task.md b/docs/phase2_taskes/task-10-staff/task.md new file mode 100644 index 00000000..68592cbe --- /dev/null +++ b/docs/phase2_taskes/task-10-staff/task.md @@ -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` خالی است | diff --git a/docs/phase2_taskes/task-10-staff/user_flow.md b/docs/phase2_taskes/task-10-staff/user_flow.md new file mode 100644 index 00000000..7f55ca97 --- /dev/null +++ b/docs/phase2_taskes/task-10-staff/user_flow.md @@ -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` diff --git a/docs/phase2_taskes/task-11-subscription/architecture.md b/docs/phase2_taskes/task-11-subscription/architecture.md new file mode 100644 index 00000000..1e6c60fa --- /dev/null +++ b/docs/phase2_taskes/task-11-subscription/architecture.md @@ -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 +``` diff --git a/docs/phase2_taskes/task-11-subscription/database.md b/docs/phase2_taskes/task-11-subscription/database.md new file mode 100644 index 00000000..c50bc701 --- /dev/null +++ b/docs/phase2_taskes/task-11-subscription/database.md @@ -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` diff --git a/docs/phase2_taskes/task-11-subscription/task.md b/docs/phase2_taskes/task-11-subscription/task.md new file mode 100644 index 00000000..49ab07b8 --- /dev/null +++ b/docs/phase2_taskes/task-11-subscription/task.md @@ -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) +``` diff --git a/docs/phase2_taskes/task-11-subscription/user_flow.md b/docs/phase2_taskes/task-11-subscription/user_flow.md new file mode 100644 index 00000000..8d7b1f5a --- /dev/null +++ b/docs/phase2_taskes/task-11-subscription/user_flow.md @@ -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 │ +│ رایگان │ پرونده بیمار ✅ │ پرونده بیمار ✅ │ +│ ۱ منشی │ سرویس‌ها ✅ │ سرویس‌ها ✅ │ +│ │ ۲ منشی │ ۵ منشی │ +│ │ ┌──────────┐ │ │ +│ │ │ ۱ ماهه │ │ [انتخاب دوره ▼] │ +│ │ │ ۲۵۰,۰۰۰ │ │ │ +│ │ │ ۶ ماهه │ │ │ +│ │ │ ۱,۲۰۰,۰۰۰│ │ │ +│ │ └──────────┘ │ │ +│ │ [خرید اشتراک] │ [خرید اشتراک] │ +└───────────┴──────────────────┴─────────────────────┘ +``` diff --git a/docs/phase2_taskes/task-12-secretary-completion/architecture.md b/docs/phase2_taskes/task-12-secretary-completion/architecture.md new file mode 100644 index 00000000..afeb0818 --- /dev/null +++ b/docs/phase2_taskes/task-12-secretary-completion/architecture.md @@ -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') +``` diff --git a/docs/phase2_taskes/task-12-secretary-completion/database.md b/docs/phase2_taskes/task-12-secretary-completion/database.md new file mode 100644 index 00000000..4d52a93c --- /dev/null +++ b/docs/phase2_taskes/task-12-secretary-completion/database.md @@ -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 | | diff --git a/docs/phase2_taskes/task-12-secretary-completion/task.md b/docs/phase2_taskes/task-12-secretary-completion/task.md new file mode 100644 index 00000000..fb5142c9 --- /dev/null +++ b/docs/phase2_taskes/task-12-secretary-completion/task.md @@ -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": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید." + }] +} +``` diff --git a/docs/phase2_taskes/task-12-secretary-completion/user_flow.md b/docs/phase2_taskes/task-12-secretary-completion/user_flow.md new file mode 100644 index 00000000..b4b829c9 --- /dev/null +++ b/docs/phase2_taskes/task-12-secretary-completion/user_flow.md @@ -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 داری) +``` diff --git a/docs/phase2_taskes/task-13-clinic-services/architecture.md b/docs/phase2_taskes/task-13-clinic-services/architecture.md new file mode 100644 index 00000000..19204fa9 --- /dev/null +++ b/docs/phase2_taskes/task-13-clinic-services/architecture.md @@ -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); +} +``` diff --git a/docs/phase2_taskes/task-13-clinic-services/database.md b/docs/phase2_taskes/task-13-clinic-services/database.md new file mode 100644 index 00000000..171e302b --- /dev/null +++ b/docs/phase2_taskes/task-13-clinic-services/database.md @@ -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 می‌شود diff --git a/docs/phase2_taskes/task-13-clinic-services/task.md b/docs/phase2_taskes/task-13-clinic-services/task.md new file mode 100644 index 00000000..180a2e21 --- /dev/null +++ b/docs/phase2_taskes/task-13-clinic-services/task.md @@ -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 | سرویس در پرونده بیمار استفاده شده — قابل حذف نیست | diff --git a/docs/phase2_taskes/task-13-clinic-services/user_flow.md b/docs/phase2_taskes/task-13-clinic-services/user_flow.md new file mode 100644 index 00000000..f75f7a9b --- /dev/null +++ b/docs/phase2_taskes/task-13-clinic-services/user_flow.md @@ -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 یا بالاتر │ +│ دارد. │ +│ [مشاهده پنل‌ها] │ +└─────────────────────────────────────────────┘ +``` diff --git a/docs/phase2_taskes/task-14-sms-panel/architecture.md b/docs/phase2_taskes/task-14-sms-panel/architecture.md new file mode 100644 index 00000000..ab6ac695 --- /dev/null +++ b/docs/phase2_taskes/task-14-sms-panel/architecture.md @@ -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; +} +``` diff --git a/docs/phase2_taskes/task-14-sms-panel/database.md b/docs/phase2_taskes/task-14-sms-panel/database.md new file mode 100644 index 00000000..8e64a83a --- /dev/null +++ b/docs/phase2_taskes/task-14-sms-panel/database.md @@ -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) diff --git a/docs/phase2_taskes/task-14-sms-panel/task.md b/docs/phase2_taskes/task-14-sms-panel/task.md new file mode 100644 index 00000000..7cbb9464 --- /dev/null +++ b/docs/phase2_taskes/task-14-sms-panel/task.md @@ -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 +``` diff --git a/docs/phase2_taskes/task-14-sms-panel/user_flow.md b/docs/phase2_taskes/task-14-sms-panel/user_flow.md new file mode 100644 index 00000000..aa3c8a83 --- /dev/null +++ b/docs/phase2_taskes/task-14-sms-panel/user_flow.md @@ -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 + +``` +┌─────────────────────────────────────────────────┐ +│ تنظیمات پیامک │ +├──────────────────────┬──────────────────────────┤ +│ کیف پول پیامک │ تنظیمات ارسال │ +│ │ │ +│ موجودی: │ یادآوری: ✅ ۳ ساعت قبل │ +│ ۱۵۰,۰۰۰ ریال │ │ +│ ≈ ۶۰۰ پیامک │ پس از ویزیت: ☐ غیرفعال │ +│ │ │ +│ [شارژ کیف پول] │ [ذخیره تنظیمات] │ +├──────────────────────┴──────────────────────────┤ +│ تاریخچه تراکنش‌ها │ +│ ✅ +۵۰۰,۰۰۰ ریال — شارژ — ۱۴۰۵/۰۳/۱۰ │ +│ ⬇️ -۲۵۰ ریال — یادآوری نوبت — ۱۴۰۵/۰۳/۱۱ │ +└─────────────────────────────────────────────────┘ +``` diff --git a/docs/phase2_taskes/task-15-patient-records/architecture.md b/docs/phase2_taskes/task-15-patient-records/architecture.md new file mode 100644 index 00000000..4cee4005 --- /dev/null +++ b/docs/phase2_taskes/task-15-patient-records/architecture.md @@ -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); +} +``` diff --git a/docs/phase2_taskes/task-15-patient-records/database.md b/docs/phase2_taskes/task-15-patient-records/database.md new file mode 100644 index 00000000..5e731801 --- /dev/null +++ b/docs/phase2_taskes/task-15-patient-records/database.md @@ -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** — نمی‌توان کاربری را که پرونده دارد حذف کرد diff --git a/docs/phase2_taskes/task-15-patient-records/task.md b/docs/phase2_taskes/task-15-patient-records/task.md new file mode 100644 index 00000000..19d9f882 --- /dev/null +++ b/docs/phase2_taskes/task-15-patient-records/task.md @@ -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 | زیربخش سرویس پیدا نشد | diff --git a/docs/phase2_taskes/task-15-patient-records/user_flow.md b/docs/phase2_taskes/task-15-patient-records/user_flow.md new file mode 100644 index 00000000..0716dc2e --- /dev/null +++ b/docs/phase2_taskes/task-15-patient-records/user_flow.md @@ -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 │ +│ ۵ مراجعه | آخرین: ۱۴۰۵/۰۳/۱۵ | مجموع: ۲,۲۵۰,۰۰۰ │ +├─────────────────────────────────────────────────────┤ +│ [+ مراجعه جدید] │ +├──────────────┬──────────────┬──────────────────────┤ +│ تاریخ │ مبلغ نهایی │ روش پرداخت عملیات │ +├──────────────┼──────────────┼──────────────────────┤ +│ ۱۴۰۵/۰۳/۱۵ │ ۴۴۵,۰۰۰ ریال│ نقدی ✏️ 👁️ │ +│ ۱۴۰۵/۰۲/۰۸ │ ۳۲۰,۰۰۰ ریال│ کارت ✏️ 👁️ │ +└──────────────┴──────────────┴──────────────────────┘ +``` diff --git a/docs/phase2_taskes/task-16-smart-dashboard/architecture.md b/docs/phase2_taskes/task-16-smart-dashboard/architecture.md new file mode 100644 index 00000000..fc6a07ab --- /dev/null +++ b/docs/phase2_taskes/task-16-smart-dashboard/architecture.md @@ -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 نوبت‌ها + + + formatDate(d)} /> + + + + + + +// chart درآمد + + + +``` + +### نصب dependency: + +```bash +ddev exec yarn add recharts +ddev exec yarn add @types/recharts # اگر نیاز بود +``` diff --git a/docs/phase2_taskes/task-16-smart-dashboard/database.md b/docs/phase2_taskes/task-16-smart-dashboard/database.md new file mode 100644 index 00000000..bcedfc0a --- /dev/null +++ b/docs/phase2_taskes/task-16-smart-dashboard/database.md @@ -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()` diff --git a/docs/phase2_taskes/task-16-smart-dashboard/task.md b/docs/phase2_taskes/task-16-smart-dashboard/task.md new file mode 100644 index 00000000..147c8f64 --- /dev/null +++ b/docs/phase2_taskes/task-16-smart-dashboard/task.md @@ -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/ +``` diff --git a/docs/phase2_taskes/task-16-smart-dashboard/user_flow.md b/docs/phase2_taskes/task-16-smart-dashboard/user_flow.md new file mode 100644 index 00000000..e9462cfc --- /dev/null +++ b/docs/phase2_taskes/task-16-smart-dashboard/user_flow.md @@ -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 }; + } +} +```