Implement SMS panel user flow and patient records system; add wallet charging, automatic reminders, and patient session management with detailed database schema and user flows.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
# معماری — تسک ۱۴: پنل پیامکی
|
||||
|
||||
## ساختار فایلها
|
||||
|
||||
```
|
||||
src/Sms/
|
||||
├── Controller/
|
||||
│ ├── SmsController.php ← موجود (تغییر نمیکند)
|
||||
│ └── SmsWalletController.php ← جدید
|
||||
├── Entity/
|
||||
│ ├── SmsLog.php ← موجود
|
||||
│ ├── SmsTemplate.php ← موجود
|
||||
│ ├── SmsWallet.php ← جدید
|
||||
│ └── SmsWalletTransaction.php ← جدید
|
||||
│ └── SmsSettings.php ← جدید
|
||||
├── Repository/
|
||||
│ └── SmsWalletRepository.php ← جدید
|
||||
└── Service/
|
||||
├── SmsService.php ← موجود — باید کسر wallet اضافه شود
|
||||
└── SmsWalletService.php ← جدید
|
||||
```
|
||||
|
||||
**فایلهایی که تغییر میکنند:**
|
||||
- `src/Payment/Entity/Payment.php` — اضافه کردن `const TYPE_SMS_WALLET = 'sms_wallet'`
|
||||
- `src/Payment/Controller/PaymentController.php` — callback برای `sms_wallet` type، شارژ wallet
|
||||
- `src/Sms/Service/SmsService.php` — قبل از ارسال، balance بررسی و کسر شود
|
||||
|
||||
## Entity: SmsWallet
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'sms_wallets')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_sms_wallet_entity', columns: ['entity_type', 'entity_id'])]
|
||||
class SmsWallet
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $balanceRials = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## Entity: SmsWalletTransaction
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'sms_wallet_transactions')]
|
||||
#[ORM\Index(columns: ['sms_wallet_id', 'created_at'], name: 'idx_sms_wallet_tx')]
|
||||
class SmsWalletTransaction
|
||||
{
|
||||
public const TYPE_CREDIT = 'credit';
|
||||
public const TYPE_DEBIT = 'debit';
|
||||
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: SmsWallet::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private SmsWallet $wallet;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $type; // 'credit' | 'debit'
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $amountRials;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: \App\Payment\Entity\Payment::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\Payment\Entity\Payment $payment = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
## Entity: SmsSettings
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'sms_settings')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_sms_settings_entity', columns: ['entity_type', 'entity_id'])]
|
||||
class SmsSettings
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $reminderEnabled = false;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $reminderHoursBefore = 2;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $postVisitEnabled = false;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $postVisitText = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
## SmsWalletService
|
||||
|
||||
```php
|
||||
class SmsWalletService
|
||||
{
|
||||
public function getOrCreate(string $entityType, int $entityId): SmsWallet
|
||||
{
|
||||
// findOneBy([entityType, entityId]) یا ایجاد جدید با balance=0
|
||||
}
|
||||
|
||||
public function charge(SmsWallet $wallet, int $amountRials, Payment $payment): void
|
||||
{
|
||||
$wallet->setBalanceRials($wallet->getBalanceRials() + $amountRials);
|
||||
// ثبت SmsWalletTransaction با type=credit
|
||||
}
|
||||
|
||||
public function deduct(SmsWallet $wallet, int $amountRials, string $description): bool
|
||||
{
|
||||
if ($wallet->getBalanceRials() < $amountRials) return false;
|
||||
$wallet->setBalanceRials($wallet->getBalanceRials() - $amountRials);
|
||||
// ثبت SmsWalletTransaction با type=debit
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## تغییر SmsService::send()
|
||||
|
||||
```php
|
||||
public function send(string $phone, string $message, string $entityType, int $entityId): bool
|
||||
{
|
||||
$priceRials = (int) $this->siteConfigRepo->getValue('sms_price_rials', '0');
|
||||
$wallet = $this->walletService->getOrCreate($entityType, $entityId);
|
||||
|
||||
if (!$this->walletService->deduct($wallet, $priceRials, 'ارسال پیامک')) {
|
||||
// لاگ: ارسال نشد — موجودی ناکافی
|
||||
return false;
|
||||
}
|
||||
|
||||
// ارسال از طریق Provider موجود ...
|
||||
return true;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
# پایگاه داده — تسک ۱۴: پنل پیامکی
|
||||
|
||||
## جدول: sms_wallets
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
|
||||
| entity_id | INT NOT NULL | |
|
||||
| balance_rials | INT NOT NULL DEFAULT 0 | موجودی فعلی |
|
||||
| created_at | INT NOT NULL | |
|
||||
| updated_at | INT NOT NULL | |
|
||||
| UNIQUE | (entity_type, entity_id) | یک wallet به ازای هر entity |
|
||||
|
||||
## جدول: sms_wallet_transactions
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| sms_wallet_id | INT NOT NULL FK→sms_wallets.id ON DELETE CASCADE | |
|
||||
| type | VARCHAR(10) NOT NULL | `'credit'` \| `'debit'` |
|
||||
| amount_rials | INT NOT NULL | مبلغ (همیشه مثبت) |
|
||||
| description | VARCHAR(255) NULL | توضیح |
|
||||
| payment_id | INT NULL FK→payments.id ON DELETE SET NULL | برای شارژ |
|
||||
| created_at | INT NOT NULL | |
|
||||
|
||||
ایندکس:
|
||||
```sql
|
||||
INDEX idx_sms_wallet_tx ON sms_wallet_transactions(sms_wallet_id, created_at)
|
||||
```
|
||||
|
||||
## جدول: sms_settings
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| entity_type | VARCHAR(10) NOT NULL | |
|
||||
| entity_id | INT NOT NULL | |
|
||||
| reminder_enabled | TINYINT(1) NOT NULL DEFAULT 0 | |
|
||||
| reminder_hours_before | TINYINT NOT NULL DEFAULT 2 | چند ساعت قبل از نوبت |
|
||||
| post_visit_enabled | TINYINT(1) NOT NULL DEFAULT 0 | |
|
||||
| post_visit_text | TEXT NULL | متن پیامک بعد از ویزیت |
|
||||
| updated_at | INT NOT NULL | |
|
||||
| UNIQUE | (entity_type, entity_id) | |
|
||||
|
||||
## SiteConfig key جدید
|
||||
|
||||
| کلید | نوع | مقدار پیشفرض | توضیح |
|
||||
|------|-----|--------------|-------|
|
||||
| `sms_price_rials` | string | `'250'` | قیمت هر پیامک — ادمین تنظیم میکند |
|
||||
|
||||
## Migration نمونه
|
||||
|
||||
```sql
|
||||
CREATE TABLE sms_wallets (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
entity_type VARCHAR(10) NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
balance_rials INT NOT NULL DEFAULT 0,
|
||||
created_at INT NOT NULL,
|
||||
updated_at INT NOT NULL,
|
||||
UNIQUE KEY uniq_sms_wallet_entity (entity_type, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE sms_wallet_transactions (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid CHAR(36) NOT NULL UNIQUE,
|
||||
sms_wallet_id INT NOT NULL,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
amount_rials INT NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
payment_id INT NULL,
|
||||
created_at INT NOT NULL,
|
||||
FOREIGN KEY (sms_wallet_id) REFERENCES sms_wallets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL,
|
||||
INDEX idx_sms_wallet_tx (sms_wallet_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE sms_settings (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
entity_type VARCHAR(10) NOT NULL,
|
||||
entity_id INT NOT NULL,
|
||||
reminder_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
reminder_hours_before TINYINT NOT NULL DEFAULT 2,
|
||||
post_visit_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
post_visit_text TEXT NULL,
|
||||
updated_at INT NOT NULL,
|
||||
UNIQUE KEY uniq_sms_settings_entity (entity_type, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- `balance_rials` هرگز منفی نمیشود — در `SmsWalletService::deduct()` چک میشود
|
||||
- `sms_wallet_transactions` لاگ کامل تمام تراکنشها است — حذف نمیشود
|
||||
- `sms_settings` با UPSERT ذخیره میشود (اگر وجود نداشت INSERT، وگرنه UPDATE)
|
||||
@@ -0,0 +1,127 @@
|
||||
# تسک ۱۴: پنل پیامکی — کیف پول + تنظیمات (SMS Panel)
|
||||
|
||||
## توضیح
|
||||
زیرساخت SMS موجود است (`src/Sms/` — SmsLog, SmsTemplate, SmsProvider).
|
||||
این تسک **کیف پول پیامک** اختصاصی و **تنظیمات** ارسال خودکار را اضافه میکند.
|
||||
کیف پول پیامک مستقل از کیف پول مالی (`src/Settlement/`) است.
|
||||
|
||||
## Endpoint ها (همه جدید)
|
||||
|
||||
| متد | مسیر | Permission | توضیح |
|
||||
|-----|------|-----------|-------|
|
||||
| GET | `/api/v1/sms/wallet/balance` | doctor/clinic | موجودی کیف پول پیامک |
|
||||
| POST | `/api/v1/sms/wallet/charge` | doctor/clinic | شارژ کیف پول |
|
||||
| GET | `/api/v1/sms/wallet/logs` | doctor/clinic | تاریخچه کسر/شارژ |
|
||||
| GET | `/api/v1/sms/settings` | doctor/clinic | دریافت تنظیمات |
|
||||
| PATCH | `/api/v1/sms/settings` | doctor/clinic | ذخیره تنظیمات |
|
||||
| GET | `/api/v1/admin/sms/wallet-report` | ROLE_ADMIN | گزارش مصرف و درآمد |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۱۷ (SMS infrastructure — موجود)
|
||||
- تسک ۱۵-payment (Payment gateway — موجود)
|
||||
|
||||
## زمان تخمینی
|
||||
۱۰ تا ۱۲ ساعت
|
||||
|
||||
## نمونه Request
|
||||
|
||||
### POST /api/v1/sms/wallet/charge
|
||||
```json
|
||||
{
|
||||
"gateway": "mellat",
|
||||
"amount_rials": 500000
|
||||
}
|
||||
```
|
||||
→ redirect به gateway (مثل payment نوبت، اما `Payment.type = 'sms_wallet'`)
|
||||
|
||||
### PATCH /api/v1/sms/settings
|
||||
```json
|
||||
{
|
||||
"reminder_enabled": true,
|
||||
"reminder_hours_before": 3,
|
||||
"post_visit_enabled": false,
|
||||
"post_visit_text": null
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه Response
|
||||
|
||||
### GET /api/v1/sms/wallet/balance
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"balance_rials": 150000,
|
||||
"sms_price_rials": 250,
|
||||
"estimated_sms_count": 600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/sms/wallet/logs
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"type": "credit",
|
||||
"amount_rials": 500000,
|
||||
"description": "شارژ کیف پول پیامک",
|
||||
"created_at": 1718000000
|
||||
},
|
||||
{
|
||||
"uuid": "...",
|
||||
"type": "debit",
|
||||
"amount_rials": 250,
|
||||
"description": "ارسال پیامک یادآوری — نوبت ۱۴۰۵/۰۳/۱۵",
|
||||
"created_at": 1718001000
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/sms/settings
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"reminder_enabled": true,
|
||||
"reminder_hours_before": 3,
|
||||
"post_visit_enabled": false,
|
||||
"post_visit_text": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/admin/sms/wallet-report
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total_charged_rials": 12500000,
|
||||
"total_deducted_rials": 8750000,
|
||||
"total_sms_sent": 35000,
|
||||
"revenue_rials": 8750000,
|
||||
"by_entity": [
|
||||
{ "entity_type": "clinic", "entity_id": 5, "name": "کلینیک سلامت", "spent_rials": 1500000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SiteConfig key های مرتبط
|
||||
|
||||
| کلید | توضیح |
|
||||
|------|-------|
|
||||
| `sms_price_rials` | قیمت هر پیامک — ادمین تنظیم میکند (مثلاً ۲۵۰ ریال) |
|
||||
|
||||
## کسر خودکار هنگام ارسال پیامک
|
||||
|
||||
```
|
||||
هر بار که SmsService::send() صدا زده میشود:
|
||||
1. SmsWallet پیدا شود
|
||||
2. اگر موجودی کافی نبود → پیامک ارسال نشود + لاگ خطا
|
||||
3. اگر کافی بود → ارسال + کسر balance_rials + ثبت sms_wallet_transactions
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# جریان کاربری — تسک ۱۴: پنل پیامکی
|
||||
|
||||
## جریان شارژ کیف پول پیامک
|
||||
|
||||
```
|
||||
کاربر وارد صفحه تنظیمات → تب «پیامک» میشود
|
||||
│
|
||||
▼
|
||||
GET /api/v1/sms/wallet/balance
|
||||
→ موجودی: ۱۵۰,۰۰۰ ریال (≈ ۶۰۰ پیامک)
|
||||
│
|
||||
▼
|
||||
کاربر روی «شارژ کیف پول» کلیک میکند
|
||||
فرم: مبلغ شارژ + انتخاب gateway
|
||||
│
|
||||
▼
|
||||
POST /api/v1/sms/wallet/charge { gateway: 'mellat', amount_rials: 500000 }
|
||||
│
|
||||
▼
|
||||
PaymentController → ایجاد Payment با type='sms_wallet'
|
||||
│
|
||||
▼
|
||||
redirect به gateway بانک
|
||||
│
|
||||
▼
|
||||
callback → SmsWalletService::charge()
|
||||
→ balance_rials += 500000
|
||||
→ ثبت credit transaction
|
||||
│
|
||||
▼
|
||||
redirect به /admin/sms-settings?charged=1
|
||||
```
|
||||
|
||||
## جریان تنظیمات یادآوری خودکار
|
||||
|
||||
```
|
||||
GET /api/v1/sms/settings → فرم پر میشود
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ تنظیمات پیامک │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ یادآوری نوبت │
|
||||
│ [✅] فعال │
|
||||
│ چند ساعت قبل: [3 ساعت ▼] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ پیامک بعد از ویزیت │
|
||||
│ [☐] فعال │
|
||||
│ متن پیامک: [______________________________]│
|
||||
└─────────────────────────────────────────────┘
|
||||
│ [ذخیره تنظیمات] │
|
||||
│
|
||||
▼
|
||||
PATCH /api/v1/sms/settings
|
||||
{ reminder_enabled: true, reminder_hours_before: 3,
|
||||
post_visit_enabled: false, post_visit_text: null }
|
||||
```
|
||||
|
||||
## جریان کسر خودکار هنگام ارسال یادآوری
|
||||
|
||||
```
|
||||
Scheduler/Cronjob اجرا میشود (هر ساعت)
|
||||
│
|
||||
▼
|
||||
نوبتهایی که در X ساعت آینده هستند پیدا میشوند
|
||||
│
|
||||
▼
|
||||
برای هر نوبت:
|
||||
entity_type/entity_id پیدا میشود
|
||||
│
|
||||
▼
|
||||
SmsWallet پیدا میشود
|
||||
│
|
||||
├─► balance < sms_price_rials:
|
||||
│ لاگ خطا: «موجودی ناکافی» — پیامک ارسال نشد
|
||||
│
|
||||
└─► balance کافی:
|
||||
SmsService::send(phone, message, entityType, entityId)
|
||||
→ ارسال پیامک
|
||||
→ deduct balance
|
||||
→ ثبت debit transaction با description='یادآوری نوبت'
|
||||
```
|
||||
|
||||
## نمایش در Frontend
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ تنظیمات پیامک │
|
||||
├──────────────────────┬──────────────────────────┤
|
||||
│ کیف پول پیامک │ تنظیمات ارسال │
|
||||
│ │ │
|
||||
│ موجودی: │ یادآوری: ✅ ۳ ساعت قبل │
|
||||
│ ۱۵۰,۰۰۰ ریال │ │
|
||||
│ ≈ ۶۰۰ پیامک │ پس از ویزیت: ☐ غیرفعال │
|
||||
│ │ │
|
||||
│ [شارژ کیف پول] │ [ذخیره تنظیمات] │
|
||||
├──────────────────────┴──────────────────────────┤
|
||||
│ تاریخچه تراکنشها │
|
||||
│ ✅ +۵۰۰,۰۰۰ ریال — شارژ — ۱۴۰۵/۰۳/۱۰ │
|
||||
│ ⬇️ -۲۵۰ ریال — یادآوری نوبت — ۱۴۰۵/۰۳/۱۱ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
Reference in New Issue
Block a user