feat: Implement SMS sending functionality with KavehNegar and Rangineh providers

- Add SendSmsMessage class for encapsulating SMS message data.
- Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS.
- Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates.
- Develop SendSmsHandler for handling SMS sending messages.
- Create SmsService to manage SMS dispatching and logging.
- Add UserProfileController for managing user profiles with CRUD operations.
- Implement UserProfile entity and repository for user profile data management.
- Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
@@ -0,0 +1,54 @@
# معماری — تسک ۱۶: ماژول داشبورد دکتر
## ساختار فایل‌ها
```
src/Module/Representation/
├── Controller/
│ └── RepresentationController.php
├── Service/
│ ├── DashboardService.php ← آمار کلی
│ ├── IncomeService.php ← محاسبه درآمد
│ └── AppointmentReportService.php ← گزارش نوبت‌ها
├── Repository/
│ └── RepresentationRepository.php ← کوئری‌های پیچیده آماری
└── DTO/
└── Response/
├── DashboardResponse.php
├── YearlyIncomeResponse.php
└── AppointmentListResponse.php
```
## نمودار جریان
```
GET /representation/{uuid}
DashboardService
├─► شمارش appointments (ماه جاری، کل، pending)
├─► جمع payments (ماه جاری، کل)
└─► average_rating از doctors.average_rating
```
## کوئری درآمد سالانه (بر اساس ماه‌های شمسی)
```php
// RepresentationRepository.php
// توجه: محاسبه بر اساس ماه شمسی است، نه میلادی
// JalaliDateService بازه timestamp هر ماه را می‌دهد
public function getMonthIncome(int $representationId, int $startTs, int $endTs): float
{
return (float) $this->createQueryBuilder('p')
->select('SUM(p.amount)')
->where('p.representationId = :repr')
->andWhere('p.status = :status')
->andWhere('p.createdAt >= :start')
->andWhere('p.createdAt < :end')
->setParameters([
'repr' => $representationId,
'status' => 'received', // نه 'paid'!
'start' => (new \DateTime())->setTimestamp($startTs),
'end' => (new \DateTime())->setTimestamp($endTs),
])
->getQuery()
->getSingleScalarResult() ?? 0.0;
}
```
@@ -0,0 +1,70 @@
# پایگاه داده — تسک ۱۶: ماژول Representation
## جدول: representations
_(entity_type=clinic_pro, bundle=representation — از DB backup تأیید شده)_
| ستون | نوع | نام Drupal | توضیح |
|------|-----|-----------|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
| domain_name | VARCHAR(255) UNIQUE NULL | field_domain_name | دامنه سایت نماینده |
| city_id | INT FK → categories.id NULL | field_city | entity ref → category (city bundle) |
| state_id | INT FK → categories.id NULL | field_state | entity ref → category (state bundle) |
| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال |
| commission_percent | INT NULL | field_commission_percent | درصد کمیسیون (نوع INT نه decimal) |
| address | LONGTEXT NULL | field_address | آدرس نماینده |
| bank_account | LONGTEXT NULL | field_bank_account | اطلاعات حساب بانکی (JSON/text) |
| created_at | INT NOT NULL | created | Unix timestamp |
| updated_at | INT NOT NULL | changed | Unix timestamp |
## نمونه داده واقعی (از DB backup)
```
id=41, bundle='representation', uid=33
uuid='bc2a0518-f20d-4542-9376-c2b4fd264706'
```
## ایندکس‌ها
```sql
CREATE UNIQUE INDEX idx_representations_domain ON representations(domain_name);
CREATE INDEX idx_representations_city ON representations(city_id);
```
## روابط با جداول دیگر
- `doctors.representation_id``representations.id` (تسک ۰۵)
- `appointments.representation_id``representations.id` (تسک ۱۰)
- `payments.representation_id``representations.id` (تسک ۱۵)
## کوئری‌های آماری (بر اساس ماه شمسی)
### پرداخت‌های یک ماه شمسی (از RepresentationService.php)
```sql
-- startTimestamp و endTimestamp از JalaliDateService می‌آیند
SELECT SUM(p.amount) as total_price, COUNT(p.id) as count
FROM payments p
WHERE p.representation_id = :id
AND p.status = 'received' -- نه 'paid'!
AND p.created_at >= :startTimestamp
AND p.created_at < :endTimestamp
```
### بیماران یک نماینده (total_patients)
```sql
SELECT COUNT(DISTINCT a.patient_id)
FROM appointments a
WHERE a.representation_id = :id
```
### نوبت‌های امروز
```sql
SELECT COUNT(*)
FROM appointments a
WHERE a.representation_id = :id
AND a.start_time >= :todayStartTimestamp
AND a.start_time < :tomorrowStartTimestamp
```
## نکات مهم
- **domain_name** شامل scheme و slash انتها است: `http://yasuj-nobat.localhost:3000/`
- آمار مالی از `payments` با `status='received'` است، نه `paid`
- محاسبه ماه/سال با تقویم **شمسی** انجام می‌شود (نه میلادی)
- `JalaliDateService` باید قبل از این تسک پیاده‌سازی شده باشد
@@ -0,0 +1,133 @@
# نکات پیاده‌سازی — تسک ۱۶: ماژول Representation (داشبورد دکتر)
## مهم: این ماژول دو نقش دارد
۱. **مدیریت نمایندگی (Multi-tenant)** — هر نماینده یک دامنه دارد
۲. **داشبورد دکتر** — آمار نوبت‌ها، درآمد، بیماران
## ساختار Representation در Drupal
```php
// فیلدهای entity (clinic_pro, bundle=representation):
field_domain_name // دامنه سایت نماینده (مثل: http://yasuj-nobat.localhost:3000/)
field_city // entity reference به category (شهر)
field_active // boolean
field_commission_percent // درصد کمیسیون
// دکتر به نماینده از طریق field_representation لینک می‌شود
// نوبت هم با field_representation لینک می‌شود (از HTTP Host)
```
## شناسایی نماینده از Host
```php
// در زمان ثبت نوبت:
$host = 'http://yasuj-nobat.localhost:3000/';
$representation = $this->representationRepo->findByDomainName($host);
// در Symfony: $request->getSchemeAndHttpHost() . '/'
```
## endpoint: my-doctor (برای بیمار)
```
GET /api/v1/representation/my-doctor/{userId}
```
→ لیست دکترهایی که این کاربر نوبت گرفته را برمی‌گرداند.
فیلد search: `field_representation = $representationId`
## endpoint: my-appointments (برای نماینده)
```
GET /api/v1/representation/my-appointments/{representationId}
```
→ تمام نوبت‌های مرتبط با این نماینده
کوئری روی `appointment.field_representation = $id`
## endpoint: filter (داشبورد ماهانه)
```
GET /api/v1/representation/filter/{representationId}?timestamp=...
```
→ از `timestamp` برای تعیین ماه جاری شمسی استفاده می‌کند
```php
// محاسبه بازه ماه جاری شمسی:
$persianMonthRange = $jalaliService->getCurrentPersianMonthRange($timestamp);
$startTimestamp = $persianMonthRange['start'];
// آمار پرداخت‌ها در این ماه:
// field_status = 'received' AND field_representation = $id AND created >= $startTimestamp
```
Response:
```json
{
"payments_total": { "total_price": 12500000, "count": 25 },
"total_patients": 142,
"today_appointments": 8
}
```
## endpoint: yearly-income (درآمد سالانه)
```
GET /api/v1/representation/yearly-income/{id}?timestamp=...
```
این endpoint از تقویم **شمسی (جلالی)** استفاده می‌کند:
```php
// محاسبه بازه سال شمسی:
$persianYearRange = $jalaliService->getPersianYearRange($timestamp);
$jalaliYear = $persianYearRange['year'];
// Loop از ماه ۱ تا ۱۲ شمسی:
for ($month = 1; $month <= 12; $month++) {
$monthRange = $this->getMonthRangeByYearAndMonth($jalaliYear, $month);
$income = $this->getMonthIncome($representationId, $monthRange['start'], $monthRange['end']);
// income = SUM(payment.amount) WHERE status='received' AND representation=$id AND created BETWEEN start AND end
}
```
Response:
```json
{
"year": 1403,
"monthly_income": [
{ "month": 1, "income": 8500000 },
{ "month": 2, "income": 9200000 },
...
{ "month": 12, "income": 0 }
]
}
```
## JalaliDateService پیاده‌سازی در Symfony
از کد `custom_service/src/jalali/JalaliDateService.php` برای پیاده‌سازی استفاده کن.
توابع مورد نیاز:
```php
class JalaliDateService {
public function getCurrentPersianMonthRange(?int $timestamp): array;
// return: ['start' => timestamp, 'end' => timestamp]
public function getPersianYearRange(?int $timestamp): array;
// return: ['year' => int, 'start' => timestamp, 'end' => timestamp]
public function getMonthRangeByYearAndMonth(int $year, int $month): array;
// return: ['start' => timestamp, 'end' => timestamp]
public function gregorianToPersian(int $gy, int $gm, int $gd): array;
// return: [$jy, $jm, $jd]
public function persianToGregorian(int $jy, int $jm, int $jd): array;
// return: [$gy, $gm, $gd]
}
```
## کشینگ آمار داشبورد
آمار داشبورد را با Redis کش کن (TTL = 5 دقیقه):
```php
$cacheKey = "dashboard_representation_{$representation->getId()}";
// بعد از هر payment جدید → cache invalidate
```
## مجوزها
```
GET /representation/{uuid} → دکتر مرتبط یا ROLE_ADMIN
GET /representation/my-appointments/{id} → دکتر/نماینده یا ROLE_ADMIN
GET /representation/my-doctor/{userId} → کاربر خودش
GET /representation/filter/{id} → دکتر/نماینده یا ROLE_ADMIN
GET /representation/yearly-income/{id} → دکتر/نماینده یا ROLE_ADMIN
```
+153
View File
@@ -0,0 +1,153 @@
# تسک ۱۶: ماژول Representation (نمایندگی + داشبورد)
## توضیح
این ماژول دو کارکرد دارد:
۱. **مدیریت نمایندگی‌ها (Multi-tenant)** — هر نماینده دامنه‌ای دارد؛ نوبت‌ها و دکترها به نماینده مرتبط می‌شوند
۲. **داشبورد دکتر/نماینده** — آمار نوبت‌ها، درآمد ماهانه/سالانه، بیماران
## Endpoint ها
| متد | مسیر | توضیح | نیاز به Auth |
|-----|------|-------|-------------|
| GET | `/api/v1/representation/{uuid}` | اطلاعات نمایندگی | بله (Admin) |
| POST | `/api/v1/representations/{id}/bank-accounts` | اضافه کردن کارت بانکی | بله (Admin) |
| GET | `/api/v1/representation/my-appointments/{representationId}` | نوبت‌های نماینده | بله |
| GET | `/api/v1/representation/my-doctor/{userId}` | دکترهای یک بیمار | بله |
| GET | `/api/v1/representation/filter/{representationId}` | آمار ماه جاری شمسی | بله |
| GET | `/api/v1/representation/yearly-income/{representationId}` | درآمد سالانه شمسی | بله |
## پیش‌نیازها
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۱۰ (Appointment)، ۱۵ (Payment)
- پیاده‌سازی `JalaliDateService` (برای تبدیل تاریخ شمسی)
> **تسویه نماینده** در تسک ۱۸ پوشش داده می‌شود (کیف پول، درخواست برداشت، تأیید ادمین)
## زمان تخمینی
۸ تا ۱۰ ساعت
## Query Params
### GET /api/v1/representation/filter/{representationId}
```
?timestamp=1704067200 (اختیاری — Unix timestamp — برای تعیین ماه شمسی)
```
اگر timestamp نداده شود، ماه جاری شمسی استفاده می‌شود.
### GET /api/v1/representation/yearly-income/{representationId}
```
?timestamp=1704067200 (اختیاری — Unix timestamp — برای تعیین سال شمسی)
```
## نمونه Response‌ها
### GET /api/v1/representation/{uuid}
```json
{
"id": 1,
"uuid": "...",
"domain_name": "http://yasuj-nobat.localhost:3000/",
"city": { "id": 5, "uuid": "...", "label": "یاسوج" },
"active": true,
"commission_percent": 10.0,
"created": 1704067200,
"changed": 1716000000
}
```
### GET /api/v1/representation/filter/{id}
```json
{
"payments_total": { "total_price": 12500000, "count": 25 },
"total_patients": 142,
"today_appointments": 8
}
```
### GET /api/v1/representation/yearly-income/{id}
```json
{
"year": 1403,
"monthly_income": [
{ "month": 1, "income": 8500000 },
{ "month": 2, "income": 9200000 },
{ "month": 3, "income": 0 },
...
{ "month": 12, "income": 0 }
]
}
```
### GET /api/v1/representation/my-appointments/{id}
```json
{
"data": [
{
"id": 10, "uuid": "...",
"start_time": 1716000000, "end_time": 1716001800,
"status": "confirmed",
"slot": { "start": "09:00", "end": "09:30", "duration": 30, "location_id": 42 },
"doctor": { "id": 5, "uuid": "...", "label": "دکتر محمدی" },
"address": { "id": 42, "uuid": "...", "label": "مطب شیراز" },
"representation": { "id": 1, "uuid": "...", "label": "نمایندگی یاسوج" },
"owner": { "id": 20, "uuid": "...", "name": "علی رضایی" }
}
]
}
```
---
## POST /api/v1/representations/{id}/bank-accounts
```
ورودی:
Authorization: Bearer <token>
Content-Type: application/json
Body:
{
"card_number": "6037-9999-1234-5678",
"bank_name": "ملت",
"is_default": true
}
```
```json
// خروجی HTTP 201:
{
"success": true,
"bank_account": {
"card_number": "6037-9999-1234-5678",
"bank_name": "ملت",
"is_default": true
}
}
```
> **⚠ نکات مهم:**
> - فیلد `bank_account` در جدول Representation به‌صورت JSON Array ذخیره می‌شود
> - هر آیتم شامل: `card_number`, `bank_name`, `is_default`
> - اگر `is_default=true` باشد، `is_default` سایر کارت‌ها باید `false` شود
> - حداقل یک کارت باید `is_default=true` داشته باشد
---
## نمونه `bank_account` در GET /api/v1/representation/{uuid}
```json
{
"bank_account": [
{ "card_number": "6037-9999-1234-5678", "bank_name": "ملت", "is_default": true },
{ "card_number": "5859-3312-4455-6677", "bank_name": "صادرات", "is_default": false }
]
}
```
---
## فیلدهای Representation Entity
```
field_domain_name → دامنه (مثل: http://yasuj-nobat.localhost:3000/)
field_city → entity reference → category (شهر)
field_active → boolean
field_commission_percent → درصد کمیسیون
```