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:
@@ -0,0 +1,82 @@
|
||||
# تسکهای پیادهسازی ClinicPro در Symfony
|
||||
|
||||
## خلاصه پروژه
|
||||
مهاجرت API اپلیکیشن clinic-pro از Drupal به Symfony 7
|
||||
محیط توسعه: DDEV | PHP 8.3 | MySQL 8 | Redis
|
||||
|
||||
---
|
||||
|
||||
## لیست تسکها به ترتیب اولویت
|
||||
|
||||
| تسک | ماژول | Endpoint ها | وابستگی | زمان |
|
||||
|-----|-------|------------|---------|------|
|
||||
| [۰۱](task-01-project-setup/) | راهاندازی پروژه | — | — | ۴-۶h |
|
||||
| [۰۲](task-02-authentication/) | احراز هویت | 8 | ۰۱ | ۸-۱۰h |
|
||||
| [۰۳](task-03-user-profile/) | پروفایل کاربر | 4 | ۰۱،۰۲ | ۸-۱۰h |
|
||||
| [۰۴](task-04-blog/) | بلاگ | 7 | ۰۱،۰۲ | ۶-۸h |
|
||||
| [۰۵](task-05-doctor/) | دکتر + آدرس | 10 | ۰۱،۰۲،۰۸ | ۸-۱۰h |
|
||||
| [۰۶](task-06-clinic/) | کلینیک | 7 | ۰۱،۰۲،۰۵،۰۸ | ۶-۸h |
|
||||
| [۰۷](task-07-agent/) | نماینده | 3 | ۰۱،۰۲ | ۳-۴h |
|
||||
| [۰۸](task-08-categories/) | دستهبندیها | 10 | ۰۱،۰۲ | ۴-۵h |
|
||||
| [۰۹](task-09-appointment-settings/) | تنظیمات نوبت | 11 | ۰۱،۰۲،۰۵ | ۱۰-۱۲h |
|
||||
| [۱۰](task-10-appointment/) | نوبتدهی | 4 | ۰۱،۰۲،۰۵،۰۹،۱۵ | ۱۰-۱۲h |
|
||||
| [۱۱](task-11-insurance/) | بیمه | 4 | ۰۱،۰۲،۰۵،۰۸ | ۳-۴h |
|
||||
| [۱۲](task-12-rating-comment/) | امتیاز و نظرات | 12 | ۰۱،۰۲،۰۵،۱۰ | ۸-۱۰h |
|
||||
| [۱۳](task-13-like/) | لایک | 2 | ۰۱،۰۲ | ۲-۳h |
|
||||
| [۱۴](task-14-secretary/) | منشی | 5 | ۰۱،۰۲،۰۵ | ۴-۵h |
|
||||
| [۱۵](task-15-payment/) | پرداخت | 3 | ۰۱،۰۲،۱۰ | ۶-۸h |
|
||||
| [۱۶](task-16-representation/) | داشبورد دکتر | 5 | ۰۱،۰۲،۰۵،۱۰،۱۵ | ۸-۱۰h |
|
||||
|
||||
**مجموع endpoint ها: ~۹۵**
|
||||
**مجموع زمان تخمینی: ۱۰۰ تا ۱۲۵ ساعت**
|
||||
|
||||
---
|
||||
|
||||
## ساختار هر تسک
|
||||
|
||||
```
|
||||
task-XX-name/
|
||||
├── task.md ← شرح، endpoint ها، وابستگیها، زمان
|
||||
├── architecture.md ← ساختار فایلها، entity ها، لایهها
|
||||
├── database.md ← جداول، ستونها، ایندکسها، روابط
|
||||
├── implementation_notes.md ← نکات فنی، edge case، امنیت
|
||||
└── user_flow.md ← (فقط تسکهای پیچیده) جریان کاربری
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ترتیب پیشنهادی اجرا
|
||||
|
||||
```
|
||||
۰۱ → ۰۲ → ۰۸ → ۰۳
|
||||
↓
|
||||
۰۵ → ۰۶
|
||||
↓
|
||||
۰۴ ۰۷ ۱۱ ۰۹
|
||||
↓
|
||||
۱۰ → ۱۵ → ۱۶
|
||||
↓
|
||||
۱۲ ۱۳ ۱۴
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## دستورات DDEV پرکاربرد
|
||||
|
||||
```bash
|
||||
ddev start # شروع محیط
|
||||
ddev stop # توقف محیط
|
||||
ddev ssh # ورود به container
|
||||
|
||||
ddev exec php bin/console make:controller {Name}
|
||||
ddev exec php bin/console make:entity {Name}
|
||||
ddev exec php bin/console doctrine:migrations:diff
|
||||
ddev exec php bin/console doctrine:migrations:migrate
|
||||
ddev exec php bin/console doctrine:fixtures:load
|
||||
|
||||
ddev exec php bin/console cache:clear
|
||||
ddev exec php bin/console debug:router
|
||||
ddev exec php bin/console debug:container
|
||||
|
||||
ddev describe # مشاهده URL و پورتها
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# معماری — تسک ۰۱: راهاندازی پروژه
|
||||
|
||||
## ساختار پوشههای پروژه
|
||||
```
|
||||
clinic-pro-symfony/
|
||||
├── .ddev/
|
||||
│ ├── config.yaml
|
||||
│ └── docker-compose.redis.yaml
|
||||
├── config/
|
||||
│ ├── packages/
|
||||
│ │ ├── doctrine.yaml
|
||||
│ │ ├── lexik_jwt_authentication.yaml
|
||||
│ │ ├── nelmio_cors.yaml
|
||||
│ │ ├── framework.yaml
|
||||
│ │ ├── cache.yaml
|
||||
│ │ └── security.yaml
|
||||
│ ├── routes/
|
||||
│ │ └── api.yaml
|
||||
│ └── services.yaml
|
||||
├── src/
|
||||
│ ├── Module/
|
||||
│ │ ├── Auth/
|
||||
│ │ ├── UserProfile/
|
||||
│ │ ├── Blog/
|
||||
│ │ ├── Doctor/
|
||||
│ │ ├── Clinic/
|
||||
│ │ ├── Agent/
|
||||
│ │ ├── Category/
|
||||
│ │ ├── AppointmentSettings/
|
||||
│ │ ├── Appointment/
|
||||
│ │ ├── Insurance/
|
||||
│ │ ├── Rating/
|
||||
│ │ ├── Comment/
|
||||
│ │ ├── Like/
|
||||
│ │ ├── Secretary/
|
||||
│ │ ├── Payment/
|
||||
│ │ └── Representation/
|
||||
│ └── Shared/
|
||||
│ ├── Response/
|
||||
│ │ └── ApiResponse.php
|
||||
│ ├── Exception/
|
||||
│ │ ├── ValidationException.php
|
||||
│ │ └── NotFoundException.php
|
||||
│ ├── Trait/
|
||||
│ │ └── TimestampableTrait.php
|
||||
│ └── EventSubscriber/
|
||||
│ └── ExceptionSubscriber.php
|
||||
├── migrations/
|
||||
├── tests/
|
||||
├── public/
|
||||
│ └── index.php
|
||||
└── .env
|
||||
```
|
||||
|
||||
## ساختار داخلی هر ماژول
|
||||
```
|
||||
src/Module/{ModuleName}/
|
||||
├── Controller/
|
||||
│ └── {Name}Controller.php ← دریافت request، فراخوانی service، بازگشت response
|
||||
├── Service/
|
||||
│ └── {Name}Service.php ← منطق تجاری
|
||||
├── Repository/
|
||||
│ └── {Name}Repository.php ← کوئریهای پایگاه داده
|
||||
├── Entity/
|
||||
│ └── {Name}.php ← Doctrine ORM mapping
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ └── Create{Name}Request.php ← validation ورودی
|
||||
│ └── Response/
|
||||
│ └── {Name}Response.php ← شکلدهی خروجی JSON
|
||||
└── Voter/
|
||||
└── {Name}Voter.php ← بررسی مجوزها
|
||||
```
|
||||
|
||||
## مسئولیت هر لایه
|
||||
|
||||
| لایه | مسئولیت |
|
||||
|------|---------|
|
||||
| Controller | دریافت HTTP، اعتبارسنجی DTO، فراخوانی Service، بازگشت ApiResponse |
|
||||
| Service | منطق تجاری، فراخوانی Repository، dispatch Event |
|
||||
| Repository | تمام کوئریهای Doctrine، بدون منطق تجاری |
|
||||
| Entity | تعریف ساختار جداول با ORM Attribute |
|
||||
| DTO | اعتبارسنجی ورودی و شکلدهی خروجی |
|
||||
| Voter | بررسی اینکه چه کسی به چه چیزی دسترسی دارد |
|
||||
|
||||
## فرمت استاندارد پاسخ (ApiResponse)
|
||||
|
||||
> **⚠ فرمت رسمی از task.md است — این فایل با آن sync شده.**
|
||||
|
||||
### موفق (بدون صفحهبندی)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "..." }
|
||||
}
|
||||
```
|
||||
|
||||
### موفق (با صفحهبندی)
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [ "..." ],
|
||||
"meta": {
|
||||
"totalRecords": 47,
|
||||
"totalPages": 5,
|
||||
"currentPage": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### خطا
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "ERR_VALIDATION_001",
|
||||
"field": "mobile_number",
|
||||
"message": "فرمت شماره موبایل نادرست است"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## نمودار ارتباط لایهها
|
||||
```
|
||||
HTTP Request
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Controller │ ← Route, DTO bind, Validate
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Service │ ← Business logic, Events
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Repository │ ← Doctrine queries
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Entity │ ← Database row
|
||||
└─────────────┘
|
||||
```
|
||||
@@ -0,0 +1,94 @@
|
||||
# پایگاه داده — تسک ۰۱: راهاندازی پروژه
|
||||
|
||||
## موتور پایگاه داده
|
||||
MySQL 8.0 با موتور InnoDB و charset از نوع utf8mb4.
|
||||
DDEV به صورت پیشفرض MySQL 8 را فراهم میکند.
|
||||
|
||||
## قراردادهای کلی
|
||||
|
||||
| قرارداد | توضیح |
|
||||
|---------|-------|
|
||||
| `id` | کلید اصلی auto-increment (فقط استفاده داخلی) |
|
||||
| `uuid` | شناسه عمومی UUID v4 (استفاده در API) |
|
||||
| `created_at` | زمان ایجاد (datetime_immutable) |
|
||||
| `updated_at` | زمان آخرین ویرایش (datetime_immutable) |
|
||||
| `deleted_at` | nullable — برای soft delete جداول مهم |
|
||||
| Foreign key | `ON DELETE CASCADE` یا `ON DELETE SET NULL` بسته به نیاز |
|
||||
|
||||
## پیکربندی DDEV برای پایگاه داده
|
||||
DDEV به طور خودکار MySQL 8 راهاندازی میکند:
|
||||
```bash
|
||||
# اطلاعات اتصال از DDEV
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_NAME=db
|
||||
DB_USER=db
|
||||
DB_PASSWORD=db
|
||||
```
|
||||
|
||||
در فایل `.env`:
|
||||
```dotenv
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4"
|
||||
```
|
||||
|
||||
## Redis با DDEV
|
||||
```yaml
|
||||
# .ddev/docker-compose.redis.yaml
|
||||
version: "3.6"
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
expose:
|
||||
- "6379"
|
||||
labels:
|
||||
com.ddev.site-name: ${DDEV_SITENAME}
|
||||
com.ddev.approot: ${DDEV_APPROOT}
|
||||
```
|
||||
|
||||
## استفاده از Redis
|
||||
|
||||
| کاربرد | کلید | TTL |
|
||||
|--------|------|-----|
|
||||
| کد OTP | `otp:{mobile}` | ۱۲۰ ثانیه |
|
||||
| Rate limiting | `rate:{ip}:{endpoint}` | بسته به قانون |
|
||||
| JWT Blacklist (logout) | `jwt_blacklist:{jti}` | تا انقضای token |
|
||||
|
||||
## TimestampableTrait
|
||||
در همه Entity ها استفاده میشود:
|
||||
```php
|
||||
// src/Shared/Trait/TimestampableTrait.php
|
||||
trait TimestampableTrait
|
||||
{
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function onPreUpdate(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## استراتژی Migration
|
||||
- از `doctrine/doctrine-migrations-bundle` استفاده میشود
|
||||
- هر تسک migration فایل مخصوص خود را دارد
|
||||
- هرگز migration های قبلی ویرایش نشوند — همیشه فایل جدید بساز
|
||||
- دستور اجرا:
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:migrate
|
||||
```
|
||||
- دستور ساخت migration جدید:
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff
|
||||
```
|
||||
@@ -0,0 +1,317 @@
|
||||
# نکات پیادهسازی — تسک ۰۱: راهاندازی پروژه
|
||||
|
||||
## تفاوتهای اصلی Drupal vs Symfony
|
||||
|
||||
### CSRF Token
|
||||
در Drupal: endpoint مخصوص `GET /session/token` برای دریافت CSRF توکن وجود دارد.
|
||||
در Symfony: چون API کاملاً stateless است و JWT استفاده میشود، CSRF Token
|
||||
سنتی **نیاز نیست**. به جای آن، JWT در هر request ارسال میشود.
|
||||
→ در TASK-02 endpoint ساختگی `/session/token` پیادهسازی میشود که یک مقدار تصادفی
|
||||
برمیگرداند تا کلاینت موجود بدون تغییر کار کند.
|
||||
|
||||
### UUID
|
||||
در Drupal: UUID داخلی Drupal مدیریت میشود.
|
||||
در Symfony: از `symfony/uid` (built-in) استفاده کن — نه `ramsey/uuid`.
|
||||
→ تمام ID های عمومی در API باید UUID باشند، نه auto-increment.
|
||||
|
||||
---
|
||||
|
||||
## پیکربندی Security (security.yaml)
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml
|
||||
security:
|
||||
password_hashers:
|
||||
App\Auth\Entity\User:
|
||||
algorithm: bcrypt
|
||||
cost: 12
|
||||
|
||||
providers:
|
||||
# تنها provider — username همیشه شماره موبایل است (برای همه نقشها)
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Auth\Entity\User
|
||||
property: mobileNumber
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
|
||||
health:
|
||||
pattern: ^/health$
|
||||
security: false
|
||||
|
||||
# Endpoints کاملاً عمومی (بدون هیچ بررسی)
|
||||
public:
|
||||
pattern: ^/(api/v1/user/send-code|api/v1/user/verify-code|api/v1/user/register|oauth/token|session/token)$
|
||||
stateless: true
|
||||
security: false
|
||||
|
||||
api:
|
||||
pattern: ^/(api|oauth)/
|
||||
stateless: true
|
||||
# ۱) Password login برای doctor/clinic/secretary
|
||||
custom_authenticators:
|
||||
- App\Auth\Security\PasswordAuthenticator
|
||||
# ۲) JWT middleware — Authorization: Bearer را میخواند
|
||||
jwt: ~
|
||||
|
||||
access_control:
|
||||
# Public endpoints
|
||||
- { path: ^/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/send-code, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/session/token, roles: PUBLIC_ACCESS }
|
||||
|
||||
# Swagger — فقط در dev
|
||||
- { path: ^/api/doc, roles: PUBLIC_ACCESS, env: dev }
|
||||
|
||||
# Admin-only
|
||||
- { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN }
|
||||
|
||||
# Authenticated
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠ استاندارد JWT — Access Token در هدر، Refresh Token در Body
|
||||
|
||||
این مهمترین نکته امنیتی در مدیریت توکنهاست. دو توکن دو جای کاملاً متفاوت دارند:
|
||||
|
||||
### Access Token → فقط در `Authorization` header
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||||
```
|
||||
|
||||
**LexikJWTAuthenticationBundle** این هدر را به صورت خودکار در تمام endpoint های محافظتشده بررسی میکند.
|
||||
در Controller کد اضافهای لازم نیست.
|
||||
|
||||
**هرگز access_token را اینجا نفرست:**
|
||||
```
|
||||
❌ GET /api/endpoint?token=eyJ... ← URL — در لاگهای سرور ذخیره میشود
|
||||
❌ POST body: {"token": "eyJ..."} ← Body — افشا در لاگهای request
|
||||
❌ Cookie: access_token=eyJ... ← Cookie — باید HttpOnly باشد و CSRF لازم دارد
|
||||
```
|
||||
|
||||
### Refresh Token → فقط در body برای یک endpoint خاص
|
||||
|
||||
```json
|
||||
POST /oauth/token/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{ "refresh_token": "a8f3b2c1d0e4..." }
|
||||
```
|
||||
|
||||
Refresh Token هرگز در `Authorization` header نمیرود. این endpoint در `access_control` با `PUBLIC_ACCESS` است — تأیید هویت با خود Refresh Token انجام میشود.
|
||||
|
||||
### گردش کامل توکنها
|
||||
|
||||
```
|
||||
[ورود — یکبار]
|
||||
POST /api/v1/user/login (password)
|
||||
یا
|
||||
POST /oauth/token (OTP)
|
||||
|
||||
← Response:
|
||||
{
|
||||
"access_token": "eyJ..." ← TTL=1h — در RAM/memory ذخیره کن
|
||||
"refresh_token": "a8f3b2..." ← TTL=30d — در HttpOnly Cookie یا secure storage
|
||||
}
|
||||
|
||||
[هر request محافظتشده]
|
||||
Authorization: Bearer eyJ... ← فقط access_token
|
||||
|
||||
[وقتی access_token منقضی — 401 دریافت شد]
|
||||
POST /oauth/token/refresh
|
||||
{ "refresh_token": "a8f3b2..." }
|
||||
← Response: access_token جدید + refresh_token جدید (Rotation)
|
||||
|
||||
[خروج]
|
||||
POST /oauth/logout
|
||||
Authorization: Bearer eyJ...
|
||||
{ "refresh_token": "a8f3b2..." }
|
||||
← هر دو توکن باطل میشوند
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## پیکربندی CORS — محدود، نه باز
|
||||
|
||||
```yaml
|
||||
# config/packages/nelmio_cors.yaml
|
||||
nelmio_cors:
|
||||
defaults:
|
||||
origin_regex: true
|
||||
allow_origin:
|
||||
- '%env(CORS_ALLOW_ORIGIN)%'
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition']
|
||||
expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']
|
||||
max_age: 3600
|
||||
allow_credentials: false
|
||||
paths:
|
||||
'^/api/':
|
||||
# ⚠️ هرگز '*' نگذار — فقط دامنههای مشخص
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
'^/oauth/':
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
'^/health':
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
```
|
||||
|
||||
```dotenv
|
||||
# .env.local (production)
|
||||
CORS_ALLOW_ORIGIN=^https://(app\.clinicpro\.ir|admin\.clinicpro\.ir)$
|
||||
|
||||
# .env (development)
|
||||
CORS_ALLOW_ORIGIN=^https?://(localhost|.*\.ddev\.site)(:\d+)?$
|
||||
```
|
||||
|
||||
> **⚠ هشدار:** هرگز `allow_origin: ['*']` در production استفاده نکن.
|
||||
> این اجازه میدهد هر وبسایت مخرب درخواستهای authenticated ارسال کند.
|
||||
|
||||
---
|
||||
|
||||
## Security Headers (EventSubscriber)
|
||||
|
||||
```php
|
||||
// src/Shared/EventSubscriber/SecurityHeadersSubscriber.php
|
||||
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) return;
|
||||
|
||||
$response = $event->getResponse();
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
|
||||
if ($event->getRequest()->isSecure()) {
|
||||
$response->headers->set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=31536000; includeSubDomains'
|
||||
);
|
||||
}
|
||||
|
||||
// برای API responses، Content-Security-Policy محدود
|
||||
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
|
||||
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||
}
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [KernelEvents::RESPONSE => 'onKernelResponse'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Swagger UI — فقط در محیط Dev
|
||||
|
||||
```yaml
|
||||
# config/packages/nelmio_api_doc.yaml
|
||||
when@prod:
|
||||
nelmio_api_doc:
|
||||
# در production کاملاً غیرفعال میشود
|
||||
# route ها به /api/doc باید در routing فقط برای dev تعریف شوند
|
||||
```
|
||||
|
||||
```yaml
|
||||
# config/routes/nelmio_api_doc.yaml
|
||||
when@dev:
|
||||
app.swagger_ui:
|
||||
path: /api/doc
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger_ui
|
||||
|
||||
app.swagger_json:
|
||||
path: /api/doc.json
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger
|
||||
```
|
||||
|
||||
> **⚠** با این روش، در production هیچ route ای برای `/api/doc` وجود ندارد → 404
|
||||
|
||||
---
|
||||
|
||||
## Audit Log — جدول security_logs
|
||||
|
||||
برای رویدادهای امنیتی حساس، یک جدول جداگانه وجود دارد:
|
||||
|
||||
```sql
|
||||
CREATE TABLE security_logs (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
event_type VARCHAR(50) NOT NULL, -- 'otp_failed', 'login_success', 'role_changed', 'payment_verified', ...
|
||||
user_id INT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
user_agent VARCHAR(255) NULL,
|
||||
details JSON NULL, -- اطلاعات اضافه (بدون data حساس!)
|
||||
created_at INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sec_logs_event ON security_logs(event_type);
|
||||
CREATE INDEX idx_sec_logs_user ON security_logs(user_id);
|
||||
CREATE INDEX idx_sec_logs_created ON security_logs(created_at);
|
||||
```
|
||||
|
||||
**رویدادهایی که باید log شوند:**
|
||||
```
|
||||
otp_sent — ارسال OTP (mobile ماسکشده: 0912***1713)
|
||||
otp_failed — کد اشتباه
|
||||
otp_expired — کد منقضی
|
||||
login_success — ورود موفق
|
||||
login_failed — ورود ناموفق
|
||||
logout — خروج
|
||||
token_refreshed — Refresh Token استفاده شد
|
||||
role_changed — تغییر نقش کاربر
|
||||
user_deleted — حذف کاربر
|
||||
payment_initiated — شروع پرداخت
|
||||
payment_verified — تأیید پرداخت
|
||||
payment_failed — پرداخت ناموفق
|
||||
file_uploaded — آپلود فایل
|
||||
```
|
||||
|
||||
> **⚠ دادههای حساس را log نکن:** شماره کامل موبایل، کد OTP، شماره کارت، JWT.
|
||||
|
||||
---
|
||||
|
||||
## نکات DDEV
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console ...
|
||||
ddev composer ...
|
||||
ddev describe # مشاهده آدرسها و پورتها
|
||||
ddev ssh # ورود به container
|
||||
```
|
||||
|
||||
URL پروژه: `https://clinic-pro.ddev.site`
|
||||
|
||||
---
|
||||
|
||||
## نکات امنیتی Production
|
||||
|
||||
```
|
||||
✅ هرگز config/jwt/private.pem را در git commit نکن (.gitignore)
|
||||
✅ JWT_PASSPHRASE را قوی انتخاب کن (حداقل 32 کاراکتر تصادفی)
|
||||
✅ APP_SECRET را با openssl rand -hex 32 تولید کن
|
||||
✅ .env.local برای production (نه .env)
|
||||
✅ CORS_ALLOW_ORIGIN فقط دامنههای مشخص (نه *)
|
||||
✅ Swagger UI فقط در dev فعال است
|
||||
✅ APP_DEBUG=false در production
|
||||
```
|
||||
@@ -0,0 +1,562 @@
|
||||
# تسک ۰۱: راهاندازی پروژه و زیرساخت
|
||||
|
||||
## توضیح
|
||||
راهاندازی اولیه پروژه Symfony 7 با DDEV، نصب پکیجها، پیکربندی JWT، Doctrine ORM،
|
||||
CORS، ساختار Domain-Driven و الگوهای مشترک (BaseController، DTO، Error Codes).
|
||||
|
||||
## Endpoint ها
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/health` | Health Check | خیر |
|
||||
|
||||
## پیشنیازها
|
||||
ندارد — اولین تسک است.
|
||||
|
||||
## خروجیهای مورد انتظار
|
||||
- [ ] DDEV راهاندازی و در حال اجرا
|
||||
- [ ] پروژه Symfony 7 ایجاد شده
|
||||
- [ ] ساختار پوشه Domain-Driven تعریف شده
|
||||
- [ ] JWT authentication bundle پیکربندی شده
|
||||
- [ ] Doctrine ORM پیکربندی شده
|
||||
- [ ] CORS پیکربندی شده
|
||||
- [ ] `BaseController` با متدهای `success()` و `error()` پیاده شده
|
||||
- [ ] `BaseRepository` با متدهای مشترک پیاده شده
|
||||
- [ ] Error Codes و Error Response استاندارد تعریف شده
|
||||
- [ ] DTO pattern برای Input/Output تعریف شده
|
||||
- [ ] Rate Limiter پیکربندی شده
|
||||
- [ ] Symfony Messenger (Queue) راهاندازی شده
|
||||
- [ ] Structured Logging با Monolog پیکربندی شده
|
||||
- [ ] Swagger UI (NelmioApiDocBundle) راهاندازی شده — `/api/doc`
|
||||
- [ ] `GET /health` endpoint پیاده شده
|
||||
|
||||
## زمان تخمینی
|
||||
۶ تا ۸ ساعت
|
||||
|
||||
---
|
||||
|
||||
## مراحل راهاندازی با DDEV
|
||||
|
||||
### ۱. ایجاد پروژه Symfony (قبل از DDEV)
|
||||
```bash
|
||||
mkdir clinic-pro-symfony && cd clinic-pro-symfony
|
||||
|
||||
# ابتدا Symfony، بعد DDEV
|
||||
composer create-project symfony/skeleton . "7.*"
|
||||
|
||||
ddev config \
|
||||
--project-type=symfony \
|
||||
--php-version=8.3 \
|
||||
--docroot=public \
|
||||
--project-name=clinic-pro
|
||||
|
||||
ddev start
|
||||
```
|
||||
|
||||
### ۲. نصب پکیجها (همه در یک دستور)
|
||||
```bash
|
||||
ddev composer require \
|
||||
symfony/security-bundle \
|
||||
symfony/validator \
|
||||
symfony/serializer \
|
||||
symfony/property-access \
|
||||
symfony/property-info \
|
||||
symfony/uid \
|
||||
symfony/messenger \
|
||||
lexik/jwt-authentication-bundle \
|
||||
doctrine/doctrine-bundle \
|
||||
doctrine/doctrine-migrations-bundle \
|
||||
symfony/cache \
|
||||
nelmio/cors-bundle \
|
||||
symfony/rate-limiter \
|
||||
symfony/http-client \
|
||||
nelmio/api-doc-bundle \
|
||||
zircote/swagger-php \
|
||||
twig/twig \
|
||||
symfony/asset
|
||||
|
||||
ddev composer require --dev \
|
||||
symfony/maker-bundle \
|
||||
doctrine/data-fixtures \
|
||||
symfony/debug-bundle
|
||||
```
|
||||
|
||||
### ۳. راهاندازی Redis با DDEV addon رسمی
|
||||
```bash
|
||||
ddev get ddev/ddev-redis
|
||||
ddev restart
|
||||
```
|
||||
|
||||
### ۴. تولید کلیدهای JWT
|
||||
```bash
|
||||
ddev exec php bin/console lexik:jwt:generate-keypair
|
||||
```
|
||||
|
||||
### ۵. پیکربندی LexikJWT
|
||||
|
||||
فایل `config/packages/lexik_jwt_authentication.yaml`:
|
||||
```yaml
|
||||
lexik_jwt_authentication:
|
||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
||||
token_ttl: 3600
|
||||
```
|
||||
|
||||
### ۶. پیکربندی Symfony Messenger (Queue)
|
||||
|
||||
فایل `config/packages/messenger.yaml`:
|
||||
```yaml
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
auto_setup: true
|
||||
routing:
|
||||
'App\Shared\Message\SendSmsMessage': async
|
||||
'App\Shared\Message\SendNotificationMessage': async
|
||||
```
|
||||
|
||||
### ۷. پیکربندی Swagger
|
||||
|
||||
فایل `config/packages/nelmio_api_doc.yaml`:
|
||||
```yaml
|
||||
nelmio_api_doc:
|
||||
documentation:
|
||||
info:
|
||||
title: ClinicPro API
|
||||
description: مستندات API سیستم کلینیکپرو
|
||||
version: 1.0.0
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
security:
|
||||
- bearerAuth: []
|
||||
areas:
|
||||
path_patterns:
|
||||
- ^/api
|
||||
- ^/oauth
|
||||
- ^/health
|
||||
```
|
||||
|
||||
فایل `config/routes/nelmio_api_doc.yaml`:
|
||||
```yaml
|
||||
app.swagger_ui:
|
||||
path: /api/doc
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger_ui
|
||||
|
||||
app.swagger_json:
|
||||
path: /api/doc.json
|
||||
methods: GET
|
||||
defaults:
|
||||
_controller: nelmio_api_doc.controller.swagger
|
||||
```
|
||||
|
||||
### ۸. پیکربندی Structured Logging
|
||||
|
||||
فایل `config/packages/monolog.yaml` (بخش prod):
|
||||
```yaml
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: '%kernel.logs_dir%/%kernel.environment%.log'
|
||||
level: info
|
||||
formatter: monolog.formatter.json
|
||||
security:
|
||||
type: stream
|
||||
path: '%kernel.logs_dir%/security.log'
|
||||
level: warning
|
||||
channels: [security]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ساختار پوشه Domain-Driven (الزامی)
|
||||
|
||||
```
|
||||
src/
|
||||
├── Auth/ ← Identity Context
|
||||
│ ├── Controller/
|
||||
│ ├── Service/
|
||||
│ ├── DTO/
|
||||
│ └── Message/ ← برای ارسال OTP async
|
||||
│
|
||||
├── Doctor/ ← Clinical Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ │ ├── Doctor.php
|
||||
│ │ └── DoctorAddress.php
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ │ ├── DoctorService.php
|
||||
│ │ └── ScheduleService.php ← محاسبه free_turn و hours_of_work
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Clinic/ ← Clinical Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Appointment/ ← Scheduling Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ │ ├── AppointmentService.php
|
||||
│ │ └── SlotService.php ← محاسبه اسلاتهای خالی
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Payment/ ← Financial Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ └── Gateway/
|
||||
│ ├── PaymentGatewayInterface.php
|
||||
│ ├── MellatGateway.php
|
||||
│ └── SepGateway.php
|
||||
│
|
||||
├── Rating/ ← Community Context
|
||||
│ ├── Controller/
|
||||
│ ├── Entity/
|
||||
│ ├── Repository/
|
||||
│ ├── Service/
|
||||
│ │ └── RatingCalculatorService.php
|
||||
│ └── DTO/
|
||||
│
|
||||
├── Blog/
|
||||
├── Category/ ← Catalog Context
|
||||
├── Representation/ ← Tenant Context
|
||||
├── Secretary/
|
||||
├── Insurance/
|
||||
├── Settlement/
|
||||
├── Sms/
|
||||
│
|
||||
└── Shared/ ← کدهای مشترک
|
||||
├── Controller/
|
||||
│ └── BaseController.php
|
||||
├── Repository/
|
||||
│ └── BaseRepository.php
|
||||
├── Response/
|
||||
│ ├── ApiResponse.php
|
||||
│ └── ApiError.php
|
||||
├── DTO/
|
||||
│ └── PaginationMeta.php
|
||||
├── Exception/
|
||||
│ └── AppException.php
|
||||
├── Message/
|
||||
│ ├── SendSmsMessage.php
|
||||
│ └── SendNotificationMessage.php
|
||||
└── Constant/
|
||||
└── ErrorCodes.php
|
||||
```
|
||||
|
||||
**قانون Dependency Direction — رعایت اجباری:**
|
||||
```
|
||||
Controller → Service → Repository → Entity
|
||||
```
|
||||
هیچگاه:
|
||||
```
|
||||
Entity → Service ❌
|
||||
Repository → Controller ❌
|
||||
Service → Controller ❌
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Response استاندارد
|
||||
|
||||
### فرمت موفق
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"totalPages": 5,
|
||||
"totalRecords": 47
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### فرمت خطا
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "ERR_VALIDATION_001",
|
||||
"field": "mobile_number",
|
||||
"message": "فرمت شماره موبایل نادرست است"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes — `src/Shared/Constant/ErrorCodes.php`
|
||||
|
||||
```php
|
||||
class ErrorCodes
|
||||
{
|
||||
// Auth
|
||||
const ERR_AUTH_001 = 'توکن JWT منقضی شده یا نامعتبر است';
|
||||
const ERR_AUTH_002 = 'کد OTP نامعتبر است';
|
||||
const ERR_AUTH_003 = 'کد OTP منقضی شده است';
|
||||
const ERR_AUTH_004 = 'تعداد تلاشهای OTP به حد مجاز رسیده است';
|
||||
|
||||
// Validation
|
||||
const ERR_VALIDATION_001 = 'ورودی نامعتبر است';
|
||||
const ERR_VALIDATION_002 = 'فیلد الزامی وارد نشده است';
|
||||
|
||||
// Not Found
|
||||
const ERR_NOT_FOUND_001 = 'منبع درخواستی یافت نشد';
|
||||
|
||||
// Forbidden
|
||||
const ERR_FORBIDDEN_001 = 'دسترسی به این منبع مجاز نیست';
|
||||
|
||||
// Payment
|
||||
const ERR_PAYMENT_001 = 'درگاه پرداخت در دسترس نیست';
|
||||
const ERR_PAYMENT_002 = 'مبلغ پرداخت نامعتبر است';
|
||||
const ERR_PAYMENT_003 = 'وضعیت نوبت برای پرداخت مناسب نیست';
|
||||
|
||||
// Appointment
|
||||
const ERR_APPOINTMENT_001 = 'اسلات انتخابشده در دسترس نیست';
|
||||
const ERR_APPOINTMENT_002 = 'نوبت قابل لغو نیست';
|
||||
|
||||
// File
|
||||
const ERR_FILE_001 = 'فرمت فایل مجاز نیست';
|
||||
const ERR_FILE_002 = 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)';
|
||||
}
|
||||
```
|
||||
|
||||
### BaseController — `src/Shared/Controller/BaseController.php`
|
||||
|
||||
```php
|
||||
abstract class BaseController extends AbstractController
|
||||
{
|
||||
protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse
|
||||
{
|
||||
$response = ['success' => true, 'data' => $data];
|
||||
if (!empty($meta)) {
|
||||
$response['meta'] = $meta;
|
||||
}
|
||||
return new JsonResponse($response, $status);
|
||||
}
|
||||
|
||||
protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse
|
||||
{
|
||||
return $this->success($data, 200, [
|
||||
'totalRecords' => $total,
|
||||
'totalPages' => (int) ceil($total / $limit),
|
||||
'currentPage' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse
|
||||
{
|
||||
$err = ['code' => $code, 'message' => $message];
|
||||
if ($field) {
|
||||
$err['field'] = $field;
|
||||
}
|
||||
return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DTO Pattern
|
||||
|
||||
هر endpoint باید DTO جداگانه داشته باشد:
|
||||
|
||||
```php
|
||||
// Input DTO (Request)
|
||||
class DoctorCreateRequest
|
||||
{
|
||||
#[Assert\NotBlank(message: 'نام دکتر الزامی است')]
|
||||
public string $title;
|
||||
|
||||
#[Assert\Choice(['man', 'woman'])]
|
||||
public string $gender;
|
||||
|
||||
#[Assert\Range(min: 0, max: 100)]
|
||||
public int $experience;
|
||||
}
|
||||
|
||||
// Output DTO (Response)
|
||||
class DoctorResponse
|
||||
{
|
||||
public function __construct(private Doctor $doctor) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) $this->doctor->getId(),
|
||||
'uuid' => $this->doctor->getUuid(),
|
||||
'name' => $this->doctor->getName(),
|
||||
'gender' => $this->doctor->getGender(),
|
||||
'experience' => $this->doctor->getExperience(),
|
||||
// ...
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Check — `GET /health`
|
||||
|
||||
```json
|
||||
// Response 200:
|
||||
{
|
||||
"status": "ok",
|
||||
"checks": {
|
||||
"database": "ok",
|
||||
"redis": "ok"
|
||||
},
|
||||
"timestamp": 1748000000
|
||||
}
|
||||
|
||||
// Response 503 (اگر یکی از سرویسها down باشد):
|
||||
{
|
||||
"status": "degraded",
|
||||
"checks": {
|
||||
"database": "ok",
|
||||
"redis": "error"
|
||||
},
|
||||
"timestamp": 1748000000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Upload — محدودیتهای مشترک و امنیتی
|
||||
|
||||
```
|
||||
حداکثر حجم فایل: 5MB
|
||||
فرمتهای مجاز: image/jpeg, image/png, image/webp
|
||||
هدرهای الزامی:
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: file; filename="name.jpg"
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### ⚠ اعتبارسنجی امنیتی فایل — بررسی محتوا، نه header
|
||||
|
||||
```php
|
||||
// src/Shared/Service/FileValidatorService.php
|
||||
class FileValidatorService
|
||||
{
|
||||
// Magic bytes برای تشخیص واقعی نوع فایل
|
||||
private const ALLOWED_SIGNATURES = [
|
||||
'image/jpeg' => ["\xFF\xD8\xFF"],
|
||||
'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
|
||||
'image/webp' => ["RIFF"],
|
||||
];
|
||||
|
||||
public function validate(string $binaryContent, string $claimedFilename): void
|
||||
{
|
||||
// ۱. بررسی حجم
|
||||
if (strlen($binaryContent) > 5 * 1024 * 1024) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_002);
|
||||
}
|
||||
|
||||
// ۲. بررسی magic bytes — نه MIME از header
|
||||
$detected = false;
|
||||
foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) {
|
||||
foreach ($signatures as $sig) {
|
||||
if (str_starts_with($binaryContent, $sig)) {
|
||||
$detected = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$detected) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001);
|
||||
}
|
||||
|
||||
// ۳. Sanitize filename — جلوگیری از path traversal
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($claimedFilename));
|
||||
if (empty($safeName) || str_contains($safeName, '..')) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001);
|
||||
}
|
||||
|
||||
// ۴. پسوند باید با content مطابقت داشته باشد
|
||||
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
||||
throw new AppException(ErrorCodes::ERR_FILE_001);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Swagger — نحوه استفاده در Controller
|
||||
|
||||
```php
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Tag(name: 'Doctor')]
|
||||
class DoctorController extends BaseController
|
||||
{
|
||||
#[OA\Get(
|
||||
path: '/api/v1/doctor/{uuid}',
|
||||
summary: 'دریافت پروفایل دکتر',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true,
|
||||
schema: new OA\Schema(type: 'string', format: 'uuid'))
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'پروفایل کامل دکتر'),
|
||||
new OA\Response(response: 404, description: 'دکتر یافت نشد'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
|
||||
public function show(string $uuid): JsonResponse { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## متغیرهای محیطی (.env)
|
||||
|
||||
```dotenv
|
||||
APP_ENV=dev
|
||||
APP_SECRET=your-secret-key
|
||||
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0"
|
||||
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Queue (از Redis استفاده میکند)
|
||||
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
|
||||
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=your-passphrase
|
||||
|
||||
# Refresh Token TTL (ثانیه) — 30 روز
|
||||
REFRESH_TOKEN_TTL=2592000
|
||||
|
||||
OTP_TTL=1200
|
||||
|
||||
# SMS Providers
|
||||
KAVENEGAR_API_KEY=your-key
|
||||
RANGINEH_API_KEY=your-key
|
||||
SMS_PROVIDER=kavenegar
|
||||
|
||||
# File Upload
|
||||
MAX_FILE_SIZE_BYTES=5242880
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
# معماری — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Auth/
|
||||
├── Controller/
|
||||
│ ├── OtpController.php ← send-code, verify-code
|
||||
│ ├── AuthController.php ← register, session/token
|
||||
│ ├── OAuthController.php ← /oauth/token, /oauth/userinfo
|
||||
│ └── UserController.php ← delete, patch user
|
||||
├── Service/
|
||||
│ ├── OtpService.php ← تولید، ذخیره و تأیید OTP در Redis
|
||||
│ ├── JwtService.php ← صدور و تمدید JWT
|
||||
│ ├── CaptchaService.php ← اعتبارسنجی captcha_token
|
||||
│ └── UserService.php ← ایجاد، ویرایش، حذف کاربر
|
||||
├── Repository/
|
||||
│ └── UserRepository.php
|
||||
├── Entity/
|
||||
│ └── User.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── SendCodeRequest.php
|
||||
│ │ ├── VerifyCodeRequest.php
|
||||
│ │ ├── RegisterRequest.php
|
||||
│ │ ├── RefreshTokenRequest.php
|
||||
│ │ └── UpdateUserRequest.php
|
||||
│ └── Response/
|
||||
│ ├── TokenResponse.php
|
||||
│ └── UserInfoResponse.php
|
||||
└── Voter/
|
||||
└── UserVoter.php ← فقط owner یا admin میتواند ویرایش/حذف کند
|
||||
```
|
||||
|
||||
## نمودار جریان احراز هویت
|
||||
```
|
||||
کاربر
|
||||
│
|
||||
├─► POST /send-code
|
||||
│ └─► OtpService: تولید کد ۵ رقمی
|
||||
│ └─► Redis: ذخیره با کلید otp:{mobile} (TTL=120s)
|
||||
│ └─► SmsService: ارسال پیامک
|
||||
│
|
||||
├─► POST /verify-code
|
||||
│ └─► OtpService: تأیید کد از Redis
|
||||
│ ├─► اگر کاربر جدید: ایجاد User با وضعیت pending
|
||||
│ └─► JwtService: صدور access_token + refresh_token
|
||||
│
|
||||
└─► POST /register (با X-CSRF-Token)
|
||||
└─► UserService: تکمیل اطلاعات کاربر
|
||||
```
|
||||
|
||||
## Entity: User
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: 'users')]
|
||||
class User implements UserInterface
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\Column(length: 20, unique: true)]
|
||||
private string $mobile;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $firstName;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $lastName;
|
||||
|
||||
#[ORM\Column(length: 180, nullable: true, unique: true)]
|
||||
private ?string $email;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
private string $status = 'pending'; // pending, active, blocked
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $roles = ['ROLE_USER'];
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# پایگاه داده — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## جدول: users
|
||||
_(از بخش ۲.۱ مستند + بررسی DB backup Drupal)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT AUTO_INCREMENT PK | id | شناسه داخلی |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | شناسه عمومی UUID |
|
||||
| uid | INT FK → users.id NOT NULL | uid | ارجاع به خود جدول (self-reference — در Drupal الزامی) |
|
||||
| mobile_number | VARCHAR(20) UNIQUE NOT NULL | name | شماره موبایل — به عنوان username استفاده میشود |
|
||||
| password | VARCHAR(255) NOT NULL | pass | رمز عبور هششده با bcrypt |
|
||||
| realname | VARCHAR(255) NULL | field_realname | نام و نامخانوادگی کامل (نه first_name/last_name!) |
|
||||
| picture | VARCHAR(500) NULL | user_picture | آدرس تصویر پروفایل |
|
||||
| email | VARCHAR(180) UNIQUE NULL | mail | ایمیل (اختیاری) |
|
||||
| status | TINYINT(1) DEFAULT 1 | status | ۱=فعال، ۰=غیرفعال |
|
||||
| roles | JSON NOT NULL | — | نقشها — مثال: `{"0":"authenticated","2":"doctor"}` |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp — زمان ایجاد |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp — آخرین ویرایش |
|
||||
|
||||
> **⚠ مهم:**
|
||||
> - Drupal از `realname` (یک فیلد) استفاده میکند، **نه** `first_name` + `last_name`!
|
||||
> - timestampها نوع **INT** هستند (Unix timestamp)، نه DATETIME
|
||||
> - `status` نوع **TINYINT** است (نه ENUM)
|
||||
> - `uid` self-reference است — در Drupal هر user به خودش اشاره میکند
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_users_uuid ON users(uuid);
|
||||
CREATE UNIQUE INDEX idx_users_mobile ON users(mobile_number);
|
||||
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_status ON users(status);
|
||||
```
|
||||
|
||||
## ذخیرهسازی OTP در Redis (نه پایگاه داده)
|
||||
```
|
||||
کلید: otp:{uuid} ← UUID از /api/v1/user/send-code برگردانده میشود (نه mobile!)
|
||||
مقدار: {"code": "12345", "attempts": 0}
|
||||
TTL: 1200 ثانیه (20 دقیقه)
|
||||
```
|
||||
|
||||
## جریان OTP (MobileGrant)
|
||||
```
|
||||
1. POST /api/v1/user/send-code → {mobile, captcha_token}
|
||||
→ UUID تولید + کد OTP ذخیره در Redis با کلید otp:{uuid}
|
||||
→ UUID برگردانده میشود
|
||||
|
||||
2. POST /api/v1/user/verify-code → {mobile, captcha_token}
|
||||
→ کد از Redis با کلید otp:{uuid} تأیید میشود
|
||||
|
||||
3. POST /oauth/token → grant_type=mobile (MobileGrant)
|
||||
→ JWT token صادر میشود
|
||||
|
||||
4. GET /oauth/userinfo → Bearer token
|
||||
→ اطلاعات کاربر + شیء clinic_pro برگردانده میشود
|
||||
```
|
||||
|
||||
## نمونه پاسخ GET /oauth/userinfo (واقعی از Drupal)
|
||||
```json
|
||||
{
|
||||
"email": null,
|
||||
"email_verified": true,
|
||||
"username": "09120671710",
|
||||
"id": "22",
|
||||
"uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6",
|
||||
"created": "1762262151",
|
||||
"changed": "1762262267",
|
||||
"status": "1",
|
||||
"roles": {
|
||||
"0": "authenticated",
|
||||
"2": "doctor"
|
||||
},
|
||||
"realName": "single doctor",
|
||||
"picture": [],
|
||||
"clinic_pro": {
|
||||
"base_role": "doctor",
|
||||
"db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d",
|
||||
"my_doctors_uuid": {
|
||||
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"id": "29",
|
||||
"name": "single doctor"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **نکات `/oauth/userinfo`:**
|
||||
> - `realName` با حرف بزرگ N (camelCase)
|
||||
> - `roles` یک object است، نه array: `{"0":"authenticated","2":"doctor"}`
|
||||
> - `clinic_pro.base_role` → نقش اصلی: `doctor`, `clinic`, `doctor_s_secretary`
|
||||
> - `clinic_pro.db_uuid` → UUID موجودیت doctor/clinic در جدول clinic_pro
|
||||
> - فقط نقشهای `doctor`, `clinic`, `doctor_s_secretary` میتوانند با پسورد لاگین کنند
|
||||
|
||||
## روابط با سایر جداول
|
||||
```
|
||||
users → user_profiles (OneToOne) : تسک ۰۳
|
||||
users → doctors (OneToOne) : تسک ۰۵
|
||||
users → clinics (OneToOne) : تسک ۰۶
|
||||
users → appointments (OneToMany) : تسک ۱۰
|
||||
users → payments (OneToMany) : تسک ۱۵
|
||||
users → representations (OneToOne): تسک ۱۶
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
# نکات پیادهسازی — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## مهمترین تفاوت با طراحی اولیه
|
||||
|
||||
### جریان OTP با UUID (نه موبایل)
|
||||
در Drupal، کد OTP **با UUID** ذخیره میشود، نه با شماره موبایل:
|
||||
```php
|
||||
// ساختار ذخیرهسازی در KeyValue/Redis:
|
||||
key = uuid (تولیدشده در send-code)
|
||||
value = { "code": "12345", "mobile": "09120671713" }
|
||||
TTL = 1200 ثانیه
|
||||
```
|
||||
|
||||
`verify-code` و `oauth/token` هر دو `uuid` میخواهند، نه موبایل.
|
||||
|
||||
```php
|
||||
// OtpService.php
|
||||
public function generate(string $mobile): array
|
||||
{
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$code = $this->isDev() ? '12345' : (string) random_int(10000, 99999);
|
||||
$this->redis->setex("otp:{$uuid}", 1200, json_encode([
|
||||
'code' => $code,
|
||||
'mobile' => $mobile,
|
||||
]));
|
||||
return ['uuid' => $uuid, 'code' => $code];
|
||||
}
|
||||
|
||||
public function verify(string $uuid, string $code): bool
|
||||
{
|
||||
$data = json_decode($this->redis->get("otp:{$uuid}"), true);
|
||||
if (!$data || $data['code'] !== $code) return false;
|
||||
$this->redis->del("otp:{$uuid}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getMobileByUuid(string $uuid): ?string
|
||||
{
|
||||
$data = json_decode($this->redis->get("otp:{$uuid}"), true);
|
||||
return $data['mobile'] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
## MobileGrant در Symfony
|
||||
به جای OAuth2 کامل، یک custom JWT grant پیادهسازی کن:
|
||||
|
||||
```
|
||||
POST /oauth/token
|
||||
grant_type=mobile
|
||||
uuid=...
|
||||
code=...
|
||||
client_id=clinic-pro
|
||||
client_secret=...
|
||||
|
||||
→ OtpService::verify(uuid, code) تأیید کند
|
||||
→ getMobileByUuid(uuid) موبایل را بگیر
|
||||
→ کاربر را پیدا یا بساز
|
||||
→ JWT صادر کن
|
||||
```
|
||||
|
||||
## SMS Providers
|
||||
دو provider واقعی در Drupal:
|
||||
|
||||
**KavehNegar:**
|
||||
```php
|
||||
POST https://api.kavenegar.com/v1/{apiKey}/sms/send.json
|
||||
form: receptor={mobile}&message={code}&sender=10004346
|
||||
```
|
||||
|
||||
**Rangineh:**
|
||||
از پیادهسازی در `sms_provider/src/Plugin/SmsProvider/Rangineh.php` الگو بگیر.
|
||||
|
||||
**Interface در Symfony:**
|
||||
```php
|
||||
interface SmsProviderInterface {
|
||||
public function send(string $mobile, string $message): bool;
|
||||
}
|
||||
```
|
||||
پیکربندی در `.env`:
|
||||
```
|
||||
SMS_PROVIDER=kavenegar # kavenegar | rangineh | null (dev)
|
||||
KAVENEGAR_API_KEY=...
|
||||
KAVENEGAR_SENDER=10004346
|
||||
```
|
||||
|
||||
## Rate Limiting (مقادیر واقعی)
|
||||
```php
|
||||
// IP: 50 درخواست در ساعت
|
||||
// Mobile: 30 درخواست در ساعت
|
||||
// keys Redis:
|
||||
// rate_ip:{ip} TTL=3600
|
||||
// rate_mobile:{mobile} TTL=3600
|
||||
```
|
||||
|
||||
## TTL کد OTP: 1200 ثانیه (20 دقیقه)
|
||||
در طراحی اولیه اشتباهاً 120 ثانیه نوشته شده بود — مقدار واقعی از Drupal ۱۲۰۰ است.
|
||||
|
||||
## Flood Control (از Drupal)
|
||||
علاوه بر rate limiting، Drupal از flood control نیز استفاده میکند:
|
||||
- `oauth2_grant.mobile.failed_login_ip` — IP based
|
||||
- `oauth2_grant.mobile.failed_login_user` — User based
|
||||
در Symfony از Symfony's `RateLimiter` component جایگزین کن.
|
||||
|
||||
## سازگاری با کلاینت: GET /session/token
|
||||
```php
|
||||
return new Response(bin2hex(random_bytes(22)), 200, ['Content-Type' => 'text/plain']);
|
||||
```
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
DELETE /api/v1/user/{id} → ROLE_ADMIN
|
||||
PATCH /api/v1/user/{id} → owner یا ROLE_ADMIN
|
||||
```
|
||||
|
||||
## ساختار واقعی GET /oauth/userinfo (از سرور Drupal)
|
||||
|
||||
```json
|
||||
{
|
||||
"email": null,
|
||||
"email_verified": true,
|
||||
"username": "09120671710",
|
||||
"id": "22",
|
||||
"uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6",
|
||||
"created": "1762262151",
|
||||
"changed": "1762262267",
|
||||
"status": "1",
|
||||
"roles": {
|
||||
"0": "authenticated",
|
||||
"2": "doctor"
|
||||
},
|
||||
"realName": "single doctor",
|
||||
"picture": [],
|
||||
"clinic_pro": {
|
||||
"base_role": "doctor",
|
||||
"db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d",
|
||||
"my_doctors_uuid": {
|
||||
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"id": "29",
|
||||
"name": "single doctor"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### نکات مهم userinfo:
|
||||
- `roles` یک **object** (نه array) با کلیدهای عددی است: `{"0": "authenticated", "2": "doctor"}`
|
||||
- `id` و `uuid` از users table
|
||||
- `realName` با R بزرگ (camelCase)
|
||||
- `clinic_pro.base_role` = نقش اصلی کاربر
|
||||
- `clinic_pro.db_uuid` = UUID موجودیت مرتبط (doctor/clinic/representation)
|
||||
- `clinic_pro.db_key` = token دسترسی برای عملیات داخلی
|
||||
- `clinic_pro.my_doctors_uuid` (فقط برای doctor) = مشخصات پروفایل دکتر
|
||||
|
||||
## نقشهای مجاز برای login با password
|
||||
```
|
||||
فقط این نقشها میتوانند با POST /oauth/token (grant_type=password) لاگین کنند:
|
||||
- doctor
|
||||
- clinic
|
||||
- doctor_s_secretary
|
||||
```
|
||||
بقیه (patient, representation, admin) فقط از طریق OTP لاگین میکنند.
|
||||
@@ -0,0 +1,488 @@
|
||||
# تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## توضیح
|
||||
دو روش ورود پشتیبانی میشود:
|
||||
|
||||
**روش اول — OTP موبایل (برای بیماران و عموم):**
|
||||
`send-code` → UUID برمیگرداند → `verify-code` با UUID+code → `oauth/token` صادر میکند JWT + Refresh Token
|
||||
|
||||
**روش دوم — Username/Password (فقط برای دکتر، کلینیک، منشی):**
|
||||
`POST /api/v1/user/login` → مستقیم JWT + Refresh Token صادر میکند
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/user/send-code` | ارسال OTP، بازگشت `uuid` | خیر |
|
||||
| POST | `/api/v1/user/verify-code` | تأیید OTP با `uuid` + `code` | خیر |
|
||||
| POST | `/api/v1/user/register` | تکمیل ثبتنام | خیر |
|
||||
| POST | `/api/v1/user/login` | لاگین با username+password (دکتر/کلینیک/منشی) | خیر |
|
||||
| GET | `/session/token` | CSRF Token (سازگاری با کلاینت) | خیر |
|
||||
| POST | `/oauth/token` | صدور JWT + Refresh Token (OTP flow) | خیر |
|
||||
| POST | `/oauth/token/refresh` | تجدید JWT با Refresh Token | خیر |
|
||||
| GET | `/oauth/userinfo` | اطلاعات کاربر لاگینشده | بله |
|
||||
| POST | `/oauth/logout` | لغو توکنها | بله |
|
||||
| DELETE | `/api/v1/user/{id}` | حذف کاربر | بله (Admin) |
|
||||
| PATCH | `/api/v1/user/{id}` | ویرایش اطلاعات پایه کاربر | بله (Owner) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱ کامل شده باشد
|
||||
|
||||
## زمان تخمینی
|
||||
۱۲ تا ۱۴ ساعت
|
||||
|
||||
---
|
||||
|
||||
## جریان واقعی OTP
|
||||
|
||||
### مرحله ۱ — POST /api/v1/user/send-code
|
||||
```json
|
||||
// Request
|
||||
{ "mobile": "09120671713", "captcha_token": "" }
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"uuid": "a1b2c3d4-e5f6-...",
|
||||
"message": "کد تایید با موفقیت ارسال شد."
|
||||
}
|
||||
|
||||
// Response 429 (rate limit)
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_004", "message": "تعداد تلاشها به حد مجاز رسیده است" }]
|
||||
}
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. بررسی rate limit (IP: 50/hr، mobile: 30/hr)
|
||||
2. بررسی تعداد تلاشهای OTP برای این mobile (حداکثر 5 بار در TTL)
|
||||
3. تولید کد 5 رقمی
|
||||
4. ذخیره در Redis: key=otp:{uuid}، value={mobile, code, attempts:0}، TTL=1200s
|
||||
5. ارسال SMS async (از طریق Symfony Messenger)
|
||||
6. بازگشت uuid
|
||||
```
|
||||
|
||||
### مرحله ۲ — POST /api/v1/user/verify-code
|
||||
```json
|
||||
// Request
|
||||
{ "uuid": "a1b2c3d4-...", "code": "12345" }
|
||||
|
||||
// Response 200
|
||||
{ "message": "کد با موفقیت تایید شد.", "success": true }
|
||||
|
||||
// Response 400 — کد اشتباه
|
||||
{ "success": false, "errors": [{ "code": "ERR_AUTH_002", "message": "کد OTP نامعتبر است" }] }
|
||||
|
||||
// Response 400 — کد منقضی
|
||||
{ "success": false, "errors": [{ "code": "ERR_AUTH_003", "message": "کد OTP منقضی شده است" }] }
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. بررسی وجود key در Redis
|
||||
2. مقایسه code با hash_equals() — نه == (جلوگیری از Timing Attack)
|
||||
3. افزایش attempts در Redis
|
||||
4. اگر attempts > 5 → خطای ERR_AUTH_004 و حذف key
|
||||
5. در صورت صحت → افزودن verified:true به Redis
|
||||
```
|
||||
|
||||
```php
|
||||
// ⚠ استفاده از hash_equals برای جلوگیری از Timing Attack
|
||||
if (!hash_equals($storedCode, $submittedCode)) {
|
||||
// کد اشتباه
|
||||
}
|
||||
```
|
||||
|
||||
### مرحله ۳ — POST /oauth/token (MobileGrant)
|
||||
```
|
||||
// Request (form-data)
|
||||
grant_type=mobile
|
||||
client_id=clinic-pro
|
||||
client_secret=secret
|
||||
uuid=a1b2c3d4-...
|
||||
code=12345
|
||||
registration=true
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "a8f3b2...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. خواندن uuid از Redis — بررسی verified:true
|
||||
2. اگر کاربر جدید و registration=true → ایجاد کاربر
|
||||
3. صدور JWT (TTL=3600s)
|
||||
4. تولید Refresh Token (random_bytes(32) → bin2hex → 64 char)
|
||||
5. هش کردن Refresh Token: hash('sha256', $rawToken)
|
||||
6. ذخیره در Redis: key=refresh:{hash}، value=user_id، TTL=2592000s
|
||||
7. حذف OTP از Redis
|
||||
8. بازگشت access_token + raw refresh_token (نه hash)
|
||||
```
|
||||
|
||||
```php
|
||||
// تولید و ذخیره Refresh Token
|
||||
$rawToken = bin2hex(random_bytes(32)); // 64 کاراکتر hex
|
||||
$hashedToken = hash('sha256', $rawToken); // ذخیره hash در Redis
|
||||
$redis->setex("refresh:{$hashedToken}", 2592000, $userId);
|
||||
// ارسال rawToken به کلاینت — هرگز hash را ارسال نکن
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Refresh Token
|
||||
|
||||
### POST /oauth/token/refresh
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"refresh_token": "a8f3b2c1d0..."
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "new_token_here",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
|
||||
// Response 401 — Refresh Token نامعتبر یا منقضی
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_001", "message": "Refresh Token نامعتبر یا منقضی شده است" }]
|
||||
}
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
```
|
||||
1. هش کردن token دریافتی: hash('sha256', $submittedToken)
|
||||
2. جستجو key=refresh:{hash} در Redis
|
||||
3. اگر وجود ندارد → 401
|
||||
4. صدور JWT جدید
|
||||
5. Refresh Token Rotation:
|
||||
- حذف hash قدیمی از Redis
|
||||
- تولید rawToken جدید + hash جدید
|
||||
- ذخیره hash جدید با TTL=2592000s
|
||||
6. بازگشت access_token + rawToken جدید
|
||||
```
|
||||
|
||||
### POST /oauth/logout
|
||||
```json
|
||||
// Request — Header: Authorization: Bearer {access_token}
|
||||
// Body:
|
||||
{ "refresh_token": "a8f3b2c1d0..." }
|
||||
|
||||
// Response 200
|
||||
{ "success": true, "message": "خروج با موفقیت انجام شد" }
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
```
|
||||
1. حذف refresh:{token} از Redis
|
||||
2. افزودن JWT به blacklist: key=blacklist:{jti}، TTL=remaining_ttl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /session/token — CSRF
|
||||
|
||||
```json
|
||||
// Response 200
|
||||
{ "token": "XwZ9k2P..." }
|
||||
```
|
||||
|
||||
ذخیره در Redis: `key=csrf:{token}` با TTL=3600s
|
||||
|
||||
---
|
||||
|
||||
## GET /oauth/userinfo
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 33,
|
||||
"uuid": "...",
|
||||
"mobile_number": "09120671713",
|
||||
"realName": "علی احمدی",
|
||||
"picture": null,
|
||||
"status": 1,
|
||||
"roles": { "0": "authenticated", "2": "doctor" },
|
||||
"clinic_pro": {
|
||||
"base_role": "doctor",
|
||||
"db_uuid": "...",
|
||||
"db_key": 29,
|
||||
"my_doctors_uuid": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
| نوع | حد | پنجره |
|
||||
|-----|-----|-------|
|
||||
| IP | 50 درخواست | ساعتی |
|
||||
| Mobile | 30 درخواست | ساعتی |
|
||||
| OTP Attempts | 5 تلاش | در طول TTL (1200s) |
|
||||
|
||||
**Rate Limit Headers در Response:**
|
||||
```
|
||||
X-RateLimit-Limit: 50
|
||||
X-RateLimit-Remaining: 47
|
||||
X-RateLimit-Reset: 1748003600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SMS Providers
|
||||
|
||||
```
|
||||
اصلی: KavehNegar (KAVENEGAR_API_KEY)
|
||||
جایگزین: Rangineh (RANGINEH_API_KEY)
|
||||
Fallback logic: اگر KavehNegar خطا داد → Rangineh
|
||||
محیط dev: کد ثابت 12345 (بدون ارسال واقعی)
|
||||
```
|
||||
|
||||
ارسال SMS از طریق **Symfony Messenger** (async) انجام میشود.
|
||||
|
||||
---
|
||||
|
||||
## Redis Key Schema
|
||||
|
||||
| Key | Value | TTL |
|
||||
|-----|-------|-----|
|
||||
| `otp:{uuid}` | `{mobile, code, attempts, verified}` | 1200s |
|
||||
| `refresh:{hash}` | `user_id` | 2592000s |
|
||||
| `blacklist:{jti}` | `1` | remaining JWT TTL |
|
||||
| `csrf:{token}` | `1` | 3600s |
|
||||
| `rate_ip:{ip}` | count | 3600s |
|
||||
| `rate_mobile:{mobile}` | count | 3600s |
|
||||
|
||||
---
|
||||
|
||||
## لاگین با Username/Password (برای دکتر، کلینیک، منشی)
|
||||
|
||||
### POST /api/v1/user/login
|
||||
|
||||
**چه کسانی میتوانند استفاده کنند:**
|
||||
- کاربران با نقش `doctor`
|
||||
- کاربران با نقش `clinic`
|
||||
- کاربران با نقش `doctor_s_secretary`
|
||||
|
||||
بیماران عادی فقط از طریق OTP وارد میشوند — این endpoint برای آنها در دسترس نیست.
|
||||
|
||||
> **⚠ نام کاربری برای همه کاربران و همه نقشها = شماره موبایل است.**
|
||||
> ایمیل به عنوان username پشتیبانی نمیشود.
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"mobile_number": "09120671713",
|
||||
"password": "SecurePass123!"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "a8f3b2c1d0...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token_expires_in": 2592000
|
||||
}
|
||||
|
||||
// Response 401 — اطلاعات اشتباه
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_005", "message": "نام کاربری یا رمز عبور اشتباه است" }]
|
||||
}
|
||||
|
||||
// Response 403 — نقش کاربر اجازه ندارد از این روش استفاده کند
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_AUTH_006", "message": "این نوع حساب فقط از طریق کد OTP وارد میشود" }]
|
||||
}
|
||||
```
|
||||
|
||||
**منطق داخلی:**
|
||||
```
|
||||
1. جستجوی کاربر با mobile_number
|
||||
2. بررسی وجود کاربر و password_hash
|
||||
3. تأیید رمز با password_verify()
|
||||
4. بررسی نقش کاربر — فقط doctor / clinic / doctor_s_secretary مجاز
|
||||
5. صدور JWT (TTL=3600s)
|
||||
6. تولید Refresh Token و ذخیره SHA-256 hash در Redis
|
||||
7. ثبت رویداد login_success در security_logs
|
||||
8. بازگشت access_token + raw refresh_token
|
||||
```
|
||||
|
||||
**پیادهسازی در Symfony — Custom Authenticator:**
|
||||
|
||||
```php
|
||||
// src/Auth/Security/PasswordAuthenticator.php
|
||||
class PasswordAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return $request->getPathInfo() === '/api/v1/user/login'
|
||||
&& $request->isMethod('POST');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$mobile = $data['mobile_number'] ?? '';
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
return new Passport(
|
||||
new UserBadge($mobile, fn($m) => $this->userRepo->findByMobile($m)),
|
||||
new PasswordCredentials($password),
|
||||
[new CsrfTokenBadge('login', $data['_csrf'] ?? '')]
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
// بررسی نقش — فقط doctor/clinic/secretary
|
||||
$allowedRoles = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY'];
|
||||
if (empty(array_intersect($user->getRoles(), $allowedRoles))) {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'errors' => [['code' => 'ERR_AUTH_006', 'message' => 'این نوع حساب فقط از طریق کد OTP وارد میشود']]
|
||||
], 403);
|
||||
}
|
||||
|
||||
$accessToken = $this->jwtManager->create($user);
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$hashedToken = hash('sha256', $rawToken);
|
||||
$this->redis->setex("refresh:{$hashedToken}", 2592000, $user->getId());
|
||||
|
||||
$this->auditLog->log('login_success', $user->getId(), $request, ['method' => 'password']);
|
||||
|
||||
return new JsonResponse([
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $rawToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token_expires_in' => 2592000,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
$this->auditLog->log('login_failed', null, $request, ['reason' => $exception->getMessage()]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'errors' => [['code' => 'ERR_AUTH_005', 'message' => 'نام کاربری یا رمز عبور اشتباه است']]
|
||||
], 401);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**فیلد password در جدول users:**
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NULL;
|
||||
-- NULL برای بیمارانی که فقط OTP دارند
|
||||
-- پر شده برای doctor/clinic/secretary
|
||||
```
|
||||
|
||||
**تغییر رمز عبور (دکتر/کلینیک/منشی):**
|
||||
```json
|
||||
PATCH /api/v1/user/{uuid}/password
|
||||
Authorization: Bearer {access_token}
|
||||
|
||||
// Request
|
||||
{
|
||||
"current_password": "OldPass123!",
|
||||
"new_password": "NewPass456!",
|
||||
"new_password_confirmation": "NewPass456!"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{ "success": true, "message": "رمز عبور با موفقیت تغییر کرد" }
|
||||
```
|
||||
|
||||
**قوانین رمز عبور:**
|
||||
- حداقل ۸ کاراکتر
|
||||
- حداقل یک حرف بزرگ
|
||||
- حداقل یک عدد
|
||||
- bcrypt با cost=12
|
||||
|
||||
---
|
||||
|
||||
## ⚠ استاندارد JWT در Symfony — کجا توکن را ارسال کنیم؟
|
||||
|
||||
این یکی از مهمترین نکات امنیتی پروژه است.
|
||||
|
||||
### Access Token — همیشه در هدر Authorization
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||||
```
|
||||
|
||||
**LexikJWTAuthenticationBundle** این هدر را در firewall `api` به صورت خودکار بررسی میکند.
|
||||
هیچ کد اضافهای در Controller لازم نیست — middleware JWT مجاز بودن را تأیید میکند.
|
||||
|
||||
```yaml
|
||||
# config/packages/security.yaml
|
||||
firewalls:
|
||||
api:
|
||||
pattern: ^/(api|oauth)/
|
||||
stateless: true
|
||||
jwt: ~ # ← این خط کافی است؛ خودش هدر Authorization را میخواند
|
||||
```
|
||||
|
||||
**هرگز access_token را اینجا نفرست:**
|
||||
```
|
||||
❌ GET /api/v1/doctor?token=eyJ... ← URL param
|
||||
❌ POST /api/v1/payment body: {token: ...} ← Request body
|
||||
❌ Cookie: access_token=eyJ... ← Cookie
|
||||
```
|
||||
|
||||
### Refresh Token — فقط در body برای endpoint مخصوص
|
||||
|
||||
Refresh Token هیچوقت در `Authorization` header نمیرود. فقط یکبار و فقط به endpoint `/oauth/token/refresh` در body ارسال میشود:
|
||||
|
||||
```
|
||||
POST /oauth/token/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refresh_token": "a8f3b2c1d0e4f5..."
|
||||
}
|
||||
```
|
||||
|
||||
این endpoint در security.yaml به صورت `PUBLIC_ACCESS` است چون تأیید هویت با خود Refresh Token انجام میشود (نه با JWT).
|
||||
|
||||
### خلاصه گردش توکنها
|
||||
|
||||
```
|
||||
[ورود] → Response body:
|
||||
{
|
||||
"access_token": "eyJ..." ← ذخیره در memory (نه localStorage)
|
||||
"refresh_token": "a8f3b2..." ← ذخیره در HttpOnly Cookie یا secure storage
|
||||
}
|
||||
|
||||
[هر درخواست محافظتشده]:
|
||||
Authorization: Bearer eyJ... ← فقط access_token در header
|
||||
|
||||
[وقتی access_token منقضی شد]:
|
||||
POST /oauth/token/refresh
|
||||
body: { "refresh_token": "a8f3b2..." }
|
||||
→ Response: { "access_token": "eyJ_new...", "refresh_token": "new_refresh..." }
|
||||
|
||||
[خروج]:
|
||||
POST /oauth/logout
|
||||
Authorization: Bearer eyJ...
|
||||
body: { "refresh_token": "a8f3b2..." }
|
||||
→ هر دو توکن باطل میشوند
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# جریان کاربری — تسک ۰۲: ماژول احراز هویت
|
||||
|
||||
## جریان کامل ورود با OTP (جریان واقعی از Drupal)
|
||||
|
||||
```
|
||||
کاربر موبایل را وارد میکند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/user/send-code
|
||||
{ mobile: "09120671713", captcha_token: "" }
|
||||
│
|
||||
├─► اعتبارسنجی فرمت موبایل (/^(\+98|0)?9\d{9}$/)
|
||||
├─► بررسی rate limit IP (max 50/hour)
|
||||
├─► بررسی rate limit Mobile (max 30/hour)
|
||||
├─► تولید UUID + کد OTP
|
||||
├─► ذخیره در Redis: otp:{uuid} = {code, mobile} (TTL=1200s)
|
||||
└─► ارسال SMS
|
||||
│
|
||||
▼
|
||||
Response: { uuid: "a1b2c3d4-...", message: "..." }
|
||||
│
|
||||
▼
|
||||
کاربر کد را وارد میکند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/user/verify-code
|
||||
{ uuid: "a1b2c3d4-...", code: "12345" }
|
||||
│
|
||||
├─► بررسی وجود uuid در Redis
|
||||
├─► مقایسه code
|
||||
│ ├─► نادرست: خطا
|
||||
│ └─► درست: حذف از Redis
|
||||
└─► Response: { message: "کد با موفقیت تایید شد.", success: true }
|
||||
|
||||
⚠️ verify-code در Drupal JWT صادر نمیکند!
|
||||
JWT در مرحله بعد با /oauth/token صادر میشود.
|
||||
│
|
||||
▼
|
||||
POST /oauth/token (MobileGrant)
|
||||
grant_type=mobile
|
||||
uuid=a1b2c3d4-... ← همان uuid
|
||||
code=12345 ← همان code
|
||||
client_id=clinic-pro
|
||||
client_secret=...
|
||||
registration=true ← اگر false باشد، فقط کاربر موجود میتواند وارد شود
|
||||
│
|
||||
├─► OtpService::verify(uuid, code)
|
||||
├─► OtpService::getMobileByUuid(uuid)
|
||||
├─► بررسی وجود کاربر با این موبایل
|
||||
│ ├─► وجود دارد: ادامه
|
||||
│ └─► جدید + registration=true: ایجاد user با status=pending
|
||||
└─► صدور JWT
|
||||
│
|
||||
▼
|
||||
Response: { access_token, refresh_token, token_type: "Bearer", expires_in: 3600 }
|
||||
```
|
||||
|
||||
## جریان تمدید Token
|
||||
|
||||
```
|
||||
POST /oauth/token
|
||||
grant_type=refresh_token
|
||||
client_id=clinic-pro
|
||||
client_secret=...
|
||||
refresh_token=eyJ...
|
||||
│
|
||||
└─► بررسی refresh_token → صدور access_token جدید
|
||||
```
|
||||
|
||||
## جریان دریافت اطلاعات کاربر
|
||||
|
||||
```
|
||||
GET /oauth/userinfo
|
||||
Authorization: Bearer {access_token}
|
||||
│
|
||||
└─► decode JWT → بازگشت: sub, uuid, name, email, phone_number, scope
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# معماری — تسک ۰۳: ماژول پروفایل کاربر
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/UserProfile/
|
||||
├── Controller/
|
||||
│ └── UserProfileController.php
|
||||
├── Service/
|
||||
│ └── UserProfileService.php
|
||||
├── Repository/
|
||||
│ └── UserProfileRepository.php
|
||||
├── Entity/
|
||||
│ └── UserProfile.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateUserProfileRequest.php
|
||||
│ │ └── UpdateUserProfileRequest.php
|
||||
│ └── Response/
|
||||
│ └── UserProfileResponse.php
|
||||
└── Voter/
|
||||
└── UserProfileVoter.php
|
||||
```
|
||||
|
||||
## Entity: UserProfile
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'user_profiles')]
|
||||
class UserProfile
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(length: 200, nullable: true)]
|
||||
private ?string $name;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $birthday;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $basicInsurance;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $bloodType;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $education;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $fathersName;
|
||||
|
||||
#[ORM\Column(length: 10, nullable: true)]
|
||||
private ?string $gender;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $homePhone;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $job;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $maritalStatus;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $supplementaryInsurance;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $workPhone;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address;
|
||||
|
||||
// اطلاعات پزشکی پیچیده به صورت JSON
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $diseases;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $allergies;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $medications;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $surgeries;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $familyHistory;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $relatives;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# پایگاه داده — تسک ۰۳: ماژول پروفایل کاربر
|
||||
|
||||
## جدول: profiles
|
||||
_(entity_type=profile — از DB backup و config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id UNIQUE NOT NULL | uid | کاربر مرتبط |
|
||||
| label | VARCHAR(255) NULL | label | نام نمایشی پروفایل |
|
||||
| family | VARCHAR(25) NULL | field_family | نام خانوادگی (max 25) |
|
||||
| fathers_name | VARCHAR(255) NULL | field_fathers_name | نام پدر |
|
||||
| national_code | VARCHAR(10) NULL | field_national_code | کد ملی (max 10) |
|
||||
| national_code_approved | TINYINT(1) DEFAULT 0 | field_national_code_approved | کد ملی تأیید شده |
|
||||
| gender | VARCHAR(10) NULL | field_gender | `male` یا `female` |
|
||||
| date_of_birth | INT NULL | field_date_of_birth | تاریخ تولد (Unix timestamp) |
|
||||
| blood_type | VARCHAR(20) NULL | field_blood_type | گروه خونی |
|
||||
| marital_status | VARCHAR(30) NULL | field_marital_status | وضعیت تأهل |
|
||||
| education | VARCHAR(100) NULL | field_education | تحصیلات |
|
||||
| job | VARCHAR(100) NULL | field_job | شغل |
|
||||
| address | LONGTEXT NULL | field_address | آدرس |
|
||||
| home_phone | VARCHAR(30) NULL | field_home_phone | تلفن منزل |
|
||||
| work_phone | VARCHAR(30) NULL | field_work_phone | تلفن کار |
|
||||
| insurance_id | VARCHAR(50) NULL | field_insurance_id | شماره بیمه |
|
||||
| basic_insurance_id | INT FK → categories.id NULL | field_basic_insurance | entity ref → category (بیمه پایه) |
|
||||
| supplementary_insurance_id | INT FK → categories.id NULL | field_supplementary_insurance | entity ref → category (بیمه تکمیلی) |
|
||||
| other | LONGTEXT NULL | field_other | سایر اطلاعات |
|
||||
| sharing_with_user | TINYINT(1) DEFAULT 0 | field_sharing_with_user | اشتراک با کاربر دیگر |
|
||||
| description | LONGTEXT NULL | description | توضیحات (base field) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## مقادیر field_gender (profile — از config)
|
||||
```
|
||||
male → آقا
|
||||
female → خانم
|
||||
```
|
||||
|
||||
## مقادیر field_blood_type (از config)
|
||||
```
|
||||
a_positive → A+ a_negative → A-
|
||||
b_positive → B+ b_negative → B-
|
||||
o_positive → O+ o_negative → O-
|
||||
ab_positive → AB+ ab_negative → AB-
|
||||
```
|
||||
|
||||
## مقادیر field_education (از Manual)
|
||||
```
|
||||
diploma → دیپلم
|
||||
postgraduate_diploma → فوق دیپلم
|
||||
bachelor_s_degree → لیسانس
|
||||
master_s_degree → فوق لیسانس
|
||||
doctorate → دکترا
|
||||
```
|
||||
|
||||
## ساختار field_other (JSON — از Manual و نمونه Request واقعی)
|
||||
|
||||
```json
|
||||
{
|
||||
"disease": [
|
||||
{ "id": 1, "name": "فشار خون", "status": "false" },
|
||||
{ "id": 2, "name": "دیابت", "status": "false" }
|
||||
],
|
||||
"allergies": [
|
||||
{ "substance": "پنیسیلین", "reaction": "کهیر", "severity": "شدید" }
|
||||
],
|
||||
"medications": [
|
||||
{ "name": "لورازپام", "dose": "1mg", "frequency": "شبها قبل خواب" }
|
||||
],
|
||||
"surgeries": [
|
||||
{ "type": "آپاندکتومی", "year": 2015, "hospital": "بیمارستان امام خمینی" }
|
||||
],
|
||||
"family_history": [
|
||||
{ "relation": "پدر", "disease": "فشار خون" }
|
||||
],
|
||||
"relatives": [
|
||||
{
|
||||
"name": "علی",
|
||||
"relation": "پسر عمو",
|
||||
"contact": { "phone": "+989121234567", "email": "...", "address": "..." }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_profiles_user ON profiles(user_id);
|
||||
CREATE INDEX idx_profiles_national_code ON profiles(national_code);
|
||||
```
|
||||
|
||||
## رابطه
|
||||
- `profiles.user_id` → `users.id` (OneToOne, CASCADE DELETE)
|
||||
- `profiles.basic_insurance_id` → `categories.id`
|
||||
- `profiles.supplementary_insurance_id` → `categories.id`
|
||||
|
||||
## نمونه داده واقعی از DB backup
|
||||
```
|
||||
id=1, uid=10, label='hamed', status=1
|
||||
id=3, uid=32, label='hamed', status=1
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- `field_family` VARCHAR(25) است — نام خانوادگی کوتاه ذخیره میشود
|
||||
- `field_national_code` VARCHAR(10) است — کد ملی ۱۰ رقمی
|
||||
- `date_of_birth` از نوع timestamp (INT) است، نه DATE
|
||||
- `basic_insurance` و `supplementary_insurance` entity reference به جدول categories هستند
|
||||
- `field_sharing_with_user` احتمالاً برای اشتراک پروفایل با دکتر/منشی است
|
||||
@@ -0,0 +1,67 @@
|
||||
# نکات پیادهسازی — تسک ۰۳: ماژول پروفایل کاربر
|
||||
|
||||
## جریان بعد از ذخیره پروفایل
|
||||
طبق مستندات Drupal، بعد از ذخیره پروفایل باید:
|
||||
1. `POST /oauth/token` با refresh_token اجرا شود (دریافت access_token جدید)
|
||||
2. `GET /oauth/userinfo` اجرا شود
|
||||
|
||||
در Symfony این جریان در سمت **کلاینت** انجام میشود، نه سرور.
|
||||
→ در پاسخ POST /api/v1/user-profile، token های بهروز شده نیز برگردان:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"profile": { "uuid": "...", "name": "..." },
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ..."
|
||||
},
|
||||
"message": "پروفایل با موفقیت ذخیره شد"
|
||||
}
|
||||
```
|
||||
|
||||
## اعتبارسنجی blood_type
|
||||
مقادیر مجاز:
|
||||
```php
|
||||
#[Assert\Choice(choices: [
|
||||
'a_positive', 'a_negative',
|
||||
'b_positive', 'b_negative',
|
||||
'ab_positive', 'ab_negative',
|
||||
'o_positive', 'o_negative'
|
||||
])]
|
||||
```
|
||||
|
||||
## اعتبارسنجی gender
|
||||
```php
|
||||
#[Assert\Choice(choices: ['male', 'female', 'other'])]
|
||||
```
|
||||
|
||||
## اعتبارسنجی education
|
||||
```php
|
||||
#[Assert\Choice(choices: [
|
||||
'primary', 'secondary', 'diploma',
|
||||
'associate', 'bachelor', 'master',
|
||||
'postgraduate_diploma', 'doctorate'
|
||||
])]
|
||||
```
|
||||
|
||||
## Partial Update (PATCH)
|
||||
endpoint PATCH باید فقط فیلدهایی که ارسال شده را آپدیت کند.
|
||||
→ از `$request->request->has('field')` یا DTO با nullable fields استفاده کن.
|
||||
→ مثال: اگر فقط `diseases` در body باشد، بقیه فیلدها تغییر نکنند.
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /api/v1/user-profile → کاربر احراز هویتشده (برای خودش)
|
||||
GET /api/v1/user-profile/{uuid} → owner یا ROLE_ADMIN یا ROLE_DOCTOR (دکتر مرتبط)
|
||||
PATCH /api/v1/user-profile/{uuid} → owner یا ROLE_ADMIN
|
||||
DELETE /api/v1/user-profile/{uuid} → فقط ROLE_ADMIN
|
||||
```
|
||||
|
||||
## نکته UUID در URL
|
||||
در Drupal از UUID واقعی در URL استفاده میشد.
|
||||
در Symfony نیز همان رویکرد حفظ میشود.
|
||||
ParamConverter میتواند UUID را به Entity تبدیل کند:
|
||||
```php
|
||||
#[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])]
|
||||
public function get(UserProfile $userProfile): Response
|
||||
// Doctrine ParamConverter به طور خودکار uuid را به UserProfile تبدیل میکند
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
# تسک ۰۳: ماژول پروفایل کاربر
|
||||
|
||||
## توضیح
|
||||
پیادهسازی CRUD پروفایل پزشکی کاربر شامل اطلاعات شخصی،
|
||||
سابقه بیماری، آلرژیها، داروها، عملهای جراحی، سابقه خانوادگی و بستگان.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/user-profile` | ایجاد پروفایل | بله |
|
||||
| GET | `/api/v1/user-profile/{uuid}` | دریافت پروفایل | بله |
|
||||
| PATCH | `/api/v1/user-profile/{uuid}` | ویرایش پروفایل | بله (Owner/Admin) |
|
||||
| DELETE | `/api/v1/user-profile/{uuid}` | حذف پروفایل | بله (Admin) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱ و ۰۲ کامل شده باشند
|
||||
|
||||
## خروجیهای مورد انتظار
|
||||
- [ ] Entity پروفایل با تمام فیلدها
|
||||
- [ ] پشتیبانی از JSON column برای دادههای پزشکی پیچیده
|
||||
- [ ] بعد از ذخیره پروفایل: refresh token و get userinfo اجرا میشود
|
||||
- [ ] فقط owner یا admin میتواند پروفایل را ببیند/ویرایش کند
|
||||
|
||||
## زمان تخمینی
|
||||
۸ تا ۱۰ ساعت
|
||||
|
||||
## نمونه Request
|
||||
|
||||
### POST /api/v1/user-profile
|
||||
```json
|
||||
{
|
||||
"name": "علی رضایی",
|
||||
"description": [{ "value": "متن توضیحات", "format": "basic_html" }],
|
||||
"birthday": "1370-05-15",
|
||||
"basic_insurance": [251],
|
||||
"blood_type": "ab_negative",
|
||||
"education": "postgraduate_diploma",
|
||||
"fathers_name": "محمد",
|
||||
"gender": "male",
|
||||
"home_phone": "07433332178",
|
||||
"job": "مهندس",
|
||||
"marital_status": "married",
|
||||
"supplementary_insurance": "308",
|
||||
"work_phone": "07433332178",
|
||||
"address": "تهران، خیابان ولیعصر",
|
||||
"other": [{
|
||||
"disease": [{ "id": 1, "name": "فشار خون", "status": "true" }],
|
||||
"allergies": [{ "substance": "پنیسیلین", "reaction": "کهیر", "severity": "شدید" }],
|
||||
"medications": [{ "name": "آتنولول", "dose": "50mg", "frequency": "صبحها" }],
|
||||
"surgeries": [{ "type": "آپاندکتومی", "year": 2015, "hospital": "بیمارستان امام خمینی" }],
|
||||
"family_history": [{ "relation": "پدر", "disease": "فشار خون" }],
|
||||
"relatives": [{
|
||||
"name": "سارا",
|
||||
"relation": "خواهر",
|
||||
"contact": { "phone": "09121234567", "email": "sara@example.com", "address": "تهران" }
|
||||
}]
|
||||
}]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# معماری — تسک ۰۴: ماژول بلاگ
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Blog/
|
||||
├── Controller/
|
||||
│ ├── BlogController.php ← CRUD بلاگ
|
||||
│ └── BlogImageController.php ← آپلود تصویر
|
||||
├── Service/
|
||||
│ ├── BlogService.php
|
||||
│ └── ImageUploadService.php
|
||||
├── Repository/
|
||||
│ └── BlogRepository.php
|
||||
├── Entity/
|
||||
│ └── Blog.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateBlogRequest.php
|
||||
│ │ └── UpdateBlogRequest.php
|
||||
│ └── Response/
|
||||
│ ├── BlogResponse.php
|
||||
│ └── BlogListResponse.php
|
||||
└── Voter/
|
||||
└── BlogVoter.php
|
||||
```
|
||||
|
||||
## Entity: Blog
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'blogs')]
|
||||
class Blog
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private User $author;
|
||||
|
||||
#[ORM\Column(length: 300)]
|
||||
private string $title;
|
||||
|
||||
#[ORM\Column(length: 300, unique: true)]
|
||||
private string $slug;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $summary;
|
||||
|
||||
#[ORM\Column(length: 20, default: 'draft')]
|
||||
private string $status; // draft, published, archived
|
||||
|
||||
#[ORM\Column(type: 'integer', default: 0)]
|
||||
private int $viewCount = 0;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imagePath;
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(name: 'blog_tags')]
|
||||
private Collection $tags;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# پایگاه داده — تسک ۰۴: ماژول بلاگ
|
||||
|
||||
## ساختار واقعی از DB backup
|
||||
|
||||
جدول `blog` در Drupal base fields را دارد + ۳ custom field:
|
||||
|
||||
## جدول: blogs
|
||||
_(entity_type=blog — از DB backup تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | نویسنده |
|
||||
| title | VARCHAR(255) NULL | label | عنوان (base field Drupal) |
|
||||
| slug | VARCHAR(300) UNIQUE NOT NULL | — | اضافهشده در Symfony (در Drupal نیست!) |
|
||||
| body | LONGTEXT NULL | description__value | محتوا (HTML) |
|
||||
| status | TINYINT(1) DEFAULT 0 | status | منتشرشده/پیشنویس |
|
||||
| is_top | TINYINT(1) DEFAULT 0 | field_top | نمایش در صفحه اول |
|
||||
| image_id | INT FK → files.id NULL | field_image | تصویر شاخص (entity ref → file) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## جدول: blog_tags (ManyToMany)
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| blog_id | INT FK → blogs.id CASCADE | | |
|
||||
| category_id | INT FK → categories.id CASCADE | field_tag | تگ/دستهبندی (entity ref → category/tag bundle) |
|
||||
|
||||
## نمونه داده واقعی از DB backup
|
||||
```
|
||||
id=14, label='روش های خانگی محافظت از پوست در تابستان', uid=22, status=1
|
||||
id=15, 16, ... (22 مطلب)
|
||||
field_image: target_id=97 (file entity)
|
||||
```
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE INDEX idx_blogs_status ON blogs(status);
|
||||
CREATE INDEX idx_blogs_user ON blogs(user_id);
|
||||
CREATE INDEX idx_blogs_created ON blogs(created_at DESC);
|
||||
CREATE INDEX idx_blogs_top ON blogs(is_top);
|
||||
CREATE UNIQUE INDEX idx_blogs_slug ON blogs(slug);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- `slug` در Drupal وجود ندارد — در Symfony باید auto-generate شود از `title`
|
||||
- `image` در Drupal یک entity reference به file/media است — در Symfony مسیر فایل ذخیره میشود
|
||||
- `body` در Drupal با نام `description__value` ذخیره میشود (base field)
|
||||
- `status=1` = منتشرشده، `status=0` = پیشنویس
|
||||
@@ -0,0 +1,139 @@
|
||||
# نکات پیادهسازی — تسک ۰۴: ماژول بلاگ
|
||||
|
||||
## نگاشت فیلدهای Request → Response (مهم!)
|
||||
|
||||
در Drupal نام فیلدهای **ارسالی** با نام فیلدهای **دریافتی** متفاوت است:
|
||||
|
||||
| فیلد در Request | فیلد در Response | توضیح |
|
||||
|----------------|-----------------|-------|
|
||||
| `label` | `title` | عنوان مقاله |
|
||||
| `description` (string) | `body: {value, format}` | متن مقاله — در response به object تبدیل میشود |
|
||||
| `field_image: [{target_id}]` | `images: [{url, fid, filename, filemime, filesize}]` | تصاویر — ID ارسال، object دریافت |
|
||||
| — | `author` | نام نویسنده (computed از realname کاربر) |
|
||||
| — | `uuid` | شناسه یکتا |
|
||||
| — | `status` | وضعیت انتشار (string: "1") |
|
||||
| — | `created` / `changed` | Unix timestamp به صورت string |
|
||||
|
||||
## فرمت واقعی Response بلاگ (از سرور Drupal — endpoint 93)
|
||||
```json
|
||||
{
|
||||
"uuid": "95f6acb0-3331-4141-801e-004e8460edac",
|
||||
"title": "روش های خانگی محافظت از پوست در تابستان",
|
||||
"status": "1",
|
||||
"body": {
|
||||
"value": "متن کامل مقاله...",
|
||||
"format": "full_html"
|
||||
},
|
||||
"created": "1763536163",
|
||||
"changed": "1763537699",
|
||||
"author": "single doctor",
|
||||
"images": [
|
||||
{
|
||||
"url": "https://domain.com/sites/default/files/blog/image.png",
|
||||
"fid": "97",
|
||||
"filename": "image.png",
|
||||
"filemime": "image/png",
|
||||
"filesize": 320787
|
||||
}
|
||||
],
|
||||
"tag": [
|
||||
{
|
||||
"uuid": "24926497-fc2d-47d7-82ae-26cbbc4d6468",
|
||||
"id": "2501",
|
||||
"name": "مجله"
|
||||
},
|
||||
{
|
||||
"uuid": "6c6a488b-1051-43a2-887b-0ec7a05e52d5",
|
||||
"id": "2500",
|
||||
"name": "سلامتی"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **نکات مهم response:**
|
||||
> - `body` یک **object** است با کلیدهای `value` و `format` — نه string ساده!
|
||||
> - `format` معمولاً `"full_html"` یا `"basic_html"` است
|
||||
> - `author` از `realname` کاربر نویسنده computed میشود
|
||||
> - `created` و `changed` به صورت **string** برگردانده میشوند (نه integer)
|
||||
> - `status` به صورت string `"1"` برگردانده میشود (نه boolean)
|
||||
> - `images` و `tag` ممکن است آرایه خالی `[]` باشند
|
||||
> - response دارای `id` نیست — فقط `uuid`
|
||||
|
||||
## فرمت Request ایجاد/ویرایش بلاگ
|
||||
|
||||
### POST /api/v1/blog/
|
||||
```json
|
||||
{
|
||||
"label": "عنوان مقاله",
|
||||
"description": "متن کامل مقاله...",
|
||||
"field_image": [
|
||||
{ "target_id": 97 },
|
||||
{ "target_id": 98 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH /api/v1/blog/{uuid}
|
||||
```json
|
||||
{
|
||||
"label": "عنوان ویرایششده",
|
||||
"description": "متن ویرایششده"
|
||||
}
|
||||
```
|
||||
|
||||
## آپلود تصویر بلاگ (endpoint 99)
|
||||
قبل از ایجاد بلاگ، تصویر باید آپلود شود و `fid` آن در `field_image` استفاده شود:
|
||||
|
||||
```
|
||||
POST /file/upload/blog/blog/field_image
|
||||
Headers:
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: file; filename="image.png"
|
||||
X-CSRF-Token: {token}
|
||||
Authorization: Bearer {token}
|
||||
Response → fid که در field_image استفاده میشود
|
||||
```
|
||||
|
||||
## تولید Slug
|
||||
- از عنوان فارسی slug تولید کن
|
||||
- پکیج `cocur/slugify` را نصب کن:
|
||||
```bash
|
||||
ddev composer require cocur/slugify
|
||||
```
|
||||
- اگر slug تکراری بود، عدد به انتهای آن اضافه کن: `rahnamai-diabet-2`
|
||||
|
||||
## بلاگهای برتر (Top Blogs)
|
||||
- endpoint: `GET /api/v1/blogs/top` (نیاز به auth دارد)
|
||||
- پاسخ: آرایه مستقیم (نه object با pagination) از بلاگهایی که `is_top = 1` هستند
|
||||
- فرمت هر آیتم دقیقاً مشابه response معمولی بلاگ است
|
||||
|
||||
## لیست بلاگها با فیلتر
|
||||
```
|
||||
GET /api/v1/blogs?page=1&limit=10&title=نشانه&tag=3637
|
||||
```
|
||||
- `title`: جستجو در عنوان
|
||||
- `tag`: فیلتر بر اساس ID تگ
|
||||
|
||||
## Pagination
|
||||
```php
|
||||
$offset = ($page - 1) * $limit;
|
||||
// در Repository با limit/offset
|
||||
```
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST → احراز هویت الزامی (ROLE_DOCTOR یا ROLE_ADMIN)
|
||||
PATCH → احراز هویت الزامی (owner یا ROLE_ADMIN)
|
||||
DELETE → احراز هویت الزامی (owner یا ROLE_ADMIN)
|
||||
GET → عمومی (بدون auth)
|
||||
GET /api/v1/blogs/top → احراز هویت الزامی
|
||||
```
|
||||
|
||||
## بهینهسازی
|
||||
- view_count با یک query atomic آپدیت کن تا race condition نباشد:
|
||||
```php
|
||||
$this->em->createQuery('UPDATE Blog b SET b.viewCount = b.viewCount + 1 WHERE b.id = :id')
|
||||
->setParameter('id', $blog->getId())
|
||||
->execute();
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# تسک ۰۴: ماژول بلاگ
|
||||
|
||||
## توضیح
|
||||
پیادهسازی سیستم مدیریت مقالات (بلاگ) شامل ایجاد، ویرایش، حذف،
|
||||
نمایش لیست، بلاگهای برتر و آپلود تصویر.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/blog/` | ایجاد بلاگ جدید | بله (Admin/Doctor) |
|
||||
| PATCH | `/api/v1/blog/{uuid}` | ویرایش بلاگ | بله (Owner/Admin) |
|
||||
| DELETE | `/api/v1/blog/{uuid}` | حذف بلاگ | بله (Owner/Admin) |
|
||||
| GET | `/api/v1/blog/{uuid}` | دریافت یک بلاگ | خیر |
|
||||
| GET | `/api/v1/blogs` | لیست بلاگها (با pagination) | خیر |
|
||||
| GET | `/api/v1/blogs/top` | بلاگهای برتر | خیر |
|
||||
| POST | `/api/v1/blog/image` | آپلود تصویر بلاگ | بله |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱ و ۰۲
|
||||
|
||||
## خروجیهای مورد انتظار
|
||||
- [ ] CRUD کامل برای بلاگ
|
||||
- [ ] pagination در لیست
|
||||
- [ ] آپلود و ذخیره تصویر
|
||||
- [ ] بلاگهای برتر (براساس بازدید یا لایک)
|
||||
- [ ] slug برای SEO
|
||||
|
||||
## زمان تخمینی
|
||||
۶ تا ۸ ساعت
|
||||
|
||||
## نمونه Request/Response
|
||||
|
||||
### POST /api/v1/blog/
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"title": "راهنمای کامل دیابت",
|
||||
"body": "<p>محتوای مقاله...</p>",
|
||||
"summary": "خلاصه مقاله",
|
||||
"tags": [1, 2, 3],
|
||||
"status": "published",
|
||||
"image_uuid": "abc-..."
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"data": {
|
||||
"uuid": "72522a1d-...",
|
||||
"title": "راهنمای کامل دیابت",
|
||||
"slug": "rahnamai-kamel-diabet",
|
||||
"status": "published",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/blogs
|
||||
```
|
||||
Query params: page=1&limit=10&category=1&tag=2
|
||||
```
|
||||
|
||||
### GET /api/v1/blogs/top
|
||||
```json
|
||||
// Response
|
||||
{
|
||||
"data": [
|
||||
{ "uuid": "...", "title": "...", "views": 1250, "image": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,176 @@
|
||||
# معماری — تسک ۰۵: ماژول دکتر
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Doctor/
|
||||
├── Controller/
|
||||
│ ├── DoctorController.php ← CRUD دکتر + لیست + جستجو
|
||||
│ ├── DoctorImageController.php ← آپلود تصویر
|
||||
│ └── DoctorAddressController.php ← CRUD آدرس مطب
|
||||
├── Service/
|
||||
│ ├── DoctorService.php
|
||||
│ └── DoctorAddressService.php
|
||||
├── Repository/
|
||||
│ ├── DoctorRepository.php
|
||||
│ └── DoctorAddressRepository.php
|
||||
├── Entity/
|
||||
│ ├── Doctor.php
|
||||
│ └── DoctorAddress.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateDoctorRequest.php
|
||||
│ │ ├── UpdateDoctorRequest.php
|
||||
│ │ ├── DoctorFilterRequest.php
|
||||
│ │ ├── CreateDoctorAddressRequest.php
|
||||
│ │ └── UpdateDoctorAddressRequest.php
|
||||
│ └── Response/
|
||||
│ ├── DoctorResponse.php ← view کامل با آمار
|
||||
│ ├── DoctorListItemResponse.php ← لیست/جستجو
|
||||
│ └── DoctorAddressResponse.php
|
||||
└── Voter/
|
||||
└── DoctorVoter.php
|
||||
```
|
||||
|
||||
## Entity: Doctor (بر اساس فیلدهای واقعی Drupal)
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctors')]
|
||||
class Doctor
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private User $user; // uid در Drupal
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $name; // field_name
|
||||
|
||||
#[ORM\Column(length: 10, nullable: true)]
|
||||
private ?string $gender; // field_gender
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
private string $medicalSystemCode; // field_doctor_id (شماره نظام پزشکی)
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $activityTime; // field_activity_time (timestamp سال شروع)
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $degree; // field_degree
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $info; // field_info
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imagePath; // field_image (media)
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 3, scale: 1, options: ['default' => 3.5])]
|
||||
private float $doctorRate = 3.5; // field_doctor_rate
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 5, scale: 1, options: ['default' => 60])]
|
||||
private float $doctorRatePercentage = 60; // field_doctor_rate_percentage
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||
private bool $activeDoctorAppointment = true; // field_active_doctor_appointmen
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Representation::class)]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?Representation $representation; // field_representation (multi-tenant)
|
||||
|
||||
// ManyToMany relations (field_specialty, field_doctor_services, field_state, field_city)
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(name: 'doctor_specialties')]
|
||||
private Collection $specialties; // field_specialty (چند مقداری)
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(name: 'doctor_services')]
|
||||
private Collection $services; // field_doctor_services (چند مقداری)
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(name: 'doctor_states')]
|
||||
private Collection $states; // field_state
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||
#[ORM\JoinTable(name: 'doctor_cities')]
|
||||
private Collection $cities; // field_city
|
||||
|
||||
#[ORM\OneToMany(targetEntity: DoctorAddress::class, mappedBy: 'doctor')]
|
||||
private Collection $addresses;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## Entity: DoctorAddress (بر اساس فیلدهای واقعی Drupal)
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_addresses')]
|
||||
class DoctorAddress
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor; // field_doctor
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $name; // field_name (نام مطب/شعبه)
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address; // field_address
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $telephone; // field_telephone (نه phone!)
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 8, nullable: true)]
|
||||
private ?float $latitude; // field_latitude
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 11, scale: 8, nullable: true)]
|
||||
private ?float $longitude; // field_longitude
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## Response کامل دکتر (finalizeData)
|
||||
```json
|
||||
{
|
||||
"id": 5, "uuid": "...",
|
||||
"name": "دکتر محمدی",
|
||||
"gender": "male",
|
||||
"experience": 12,
|
||||
"activity_time": 1388534400,
|
||||
"medical_system_code": "12345",
|
||||
"detail": "متخصص قلب و عروق...",
|
||||
"degree": "دکترای تخصصی",
|
||||
"specialties": [{"id": 3, "uuid": "...", "name": "قلب و عروق"}],
|
||||
"img": ["https://..."],
|
||||
"expertise": [{"id": 10, "uuid": "...", "name": "اکوکاردیوگرافی"}],
|
||||
"satisfaction": 87.5,
|
||||
"point": 4.4,
|
||||
"free_turn": "اولین نوبت آزاد: دوشنبه ۱۲ اردیبهشت ساعت ۱۰:۳۰",
|
||||
"hours_of_work": ["شنبه", "یکشنبه", "دوشنبه"],
|
||||
"address": [...],
|
||||
"average_rate": {"average_stars": 4.3, "total_rates": 87},
|
||||
"state": [{"id": 2, "uuid": "...", "name": "فارس"}],
|
||||
"city": [{"id": 5, "uuid": "...", "name": "شیراز"}]
|
||||
}
|
||||
```
|
||||
|
||||
## منطق create دکتر (از DoctorService.php)
|
||||
۱. اگر `doctor_mobile_number` داده شد:
|
||||
- کاربر با این موبایل را پیدا کن
|
||||
- اگر نقش `doctor` ندارد → 403
|
||||
- اگر قبلاً profile دکتر دارد → 403
|
||||
- اگر کاربر وجود ندارد → کاربر جدید با نقش doctor بساز
|
||||
۲. اگر `doctor_mobile_number` نداده شد → کاربر جاری استفاده شود
|
||||
۳. اعتبارسنجی همه فیلدهای اجباری
|
||||
۴. ایجاد entity
|
||||
@@ -0,0 +1,160 @@
|
||||
# پایگاه داده — تسک ۰۵: ماژول دکتر
|
||||
|
||||
## جدول: doctors
|
||||
_(entity_type=clinic_pro, bundle=doctor — از DB backup و config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id UNIQUE NOT NULL | uid | کاربر صاحب پروفایل |
|
||||
| name | VARCHAR(255) NOT NULL | field_name | نام دکتر |
|
||||
| gender | VARCHAR(10) NULL | field_gender | `woman` یا `man` |
|
||||
| medical_system_code | VARCHAR(25) NULL | field_doctor_id | شماره نظام پزشکی (max 25) |
|
||||
| mobile_number | VARCHAR(15) NULL | field_doctor_mobile_number | شماره موبایل دکتر (max 15) |
|
||||
| activity_time | INT NULL | field_activity_time | timestamp سال شروع فعالیت |
|
||||
| degree | VARCHAR(30) NULL | field_degree | مدرک (مقادیر زیر) |
|
||||
| info | LONGTEXT NULL | field_info | بیوگرافی |
|
||||
| image_path | VARCHAR(255) NULL | field_image | تصویر (media) |
|
||||
| doctor_rate | FLOAT NULL | field_doctor_rate | امتیاز ستارهای (default: 3.5) |
|
||||
| doctor_rate_percentage | FLOAT NULL | field_doctor_rate_percentage | درصد رضایت (default: 60) |
|
||||
| active_doctor_appointment | TINYINT(1) DEFAULT 1 | field_active_doctor_appointmen | نوبتدهی فعال (بدون حرف 't') |
|
||||
| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## مقادیر field_degree (از config)
|
||||
```
|
||||
expert → کارشناس
|
||||
general → پزشک عمومی
|
||||
specialist → پزشک متخصص
|
||||
subspecialistplus → پزشک فوق تخصص
|
||||
```
|
||||
|
||||
## مقادیر field_gender (از config)
|
||||
```
|
||||
woman → زن
|
||||
man → مرد
|
||||
```
|
||||
|
||||
## جدول: doctor_specialties (ManyToMany pivot)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| doctor_id | INT FK → doctors.id | |
|
||||
| category_id | INT FK → categories.id | |
|
||||
| PRIMARY KEY (doctor_id, category_id) | | field_specialty → cardinality=2 (حداکثر ۲ تخصص) |
|
||||
|
||||
## جدول: doctor_services (ManyToMany pivot)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| doctor_id | INT FK → doctors.id | |
|
||||
| category_id | INT FK → categories.id | |
|
||||
| PRIMARY KEY (doctor_id, category_id) | | field_doctor_services → cardinality=8 (حداکثر ۸ سرویس) |
|
||||
|
||||
## جدول: doctor_states (ManyToMany pivot)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| doctor_id | INT FK → doctors.id | |
|
||||
| category_id | INT FK → categories.id | |
|
||||
| PRIMARY KEY (doctor_id, category_id) | | field_state → cardinality=1 |
|
||||
|
||||
## جدول: doctor_cities (ManyToMany pivot)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| doctor_id | INT FK → doctors.id | |
|
||||
| category_id | INT FK → categories.id | |
|
||||
| PRIMARY KEY (doctor_id, category_id) | | field_city → cardinality=1 |
|
||||
|
||||
## جدول: doctor_addresses
|
||||
_(entity_type=clinic_pro, bundle=doctors_addresses — از DB backup)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| doctor_id | INT FK → doctors.id CASCADE | field_doctor | دکتر |
|
||||
| name | VARCHAR(255) NULL | field_name | نام مطب/آدرس |
|
||||
| address | LONGTEXT NULL | field_address | آدرس کامل |
|
||||
| telephone | VARCHAR(50) NULL | field_telephone | تلفن مطب |
|
||||
| latitude | FLOAT NULL | field_latitude | عرض جغرافیایی |
|
||||
| longitude | FLOAT NULL | field_longitude | طول جغرافیایی |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## جدول: clinics
|
||||
_(entity_type=clinic_pro, bundle=clinic — از config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | مالک |
|
||||
| name | VARCHAR(255) NULL | field_name | نام کلینیک |
|
||||
| info | LONGTEXT NULL | field_info | توضیحات |
|
||||
| address | LONGTEXT NULL | field_address | آدرس |
|
||||
| telephone | VARCHAR(50) NULL | field_telephone | تلفن |
|
||||
| is_24_7 | TINYINT(1) DEFAULT 0 | field_24_7 | باز ۲۴/۷ |
|
||||
| working_days | VARCHAR(255) NULL | field_working_days | روزهای کاری (متن آزاد، مثال: 'شنبه تا سه شنبه ساعت ۱۲:۲۰') |
|
||||
| latitude | FLOAT NULL | field_latitude | |
|
||||
| longitude | FLOAT NULL | field_longitude | |
|
||||
| city_id | INT FK → categories.id NULL | field_city | |
|
||||
| state_id | INT FK → categories.id NULL | field_state | |
|
||||
| representation_id | INT FK → representations.id NULL | field_agent | نماینده کلینیک |
|
||||
| created_at | INT NOT NULL | created | |
|
||||
| updated_at | INT NOT NULL | changed | |
|
||||
|
||||
جداول pivot مرتبط با clinic:
|
||||
- `clinic_doctors` (clinic_id, doctor_id) → field_doctors (cardinality نامحدود)
|
||||
- `clinic_specialties` (clinic_id, category_id) → field_clinic_specialty
|
||||
- `clinic_services` (clinic_id, category_id) → field_doctor_services
|
||||
- `clinic_insurances` (clinic_id, category_id) → field_insurance
|
||||
|
||||
## جدول: doctor_secretaries
|
||||
_(entity_type=clinic_pro, bundle=doctor_secretary — از config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | |
|
||||
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor | entity ref → clinic_pro/doctor |
|
||||
| secretary_id | INT FK → users.id NOT NULL | field_secretary | entity ref → user (منشی) |
|
||||
| telephone | VARCHAR(50) NULL | field_telephone | تلفن |
|
||||
| permission | LONGTEXT NULL | field_permission | مجوزها (JSON) |
|
||||
| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال |
|
||||
| created_at | INT NOT NULL | created | |
|
||||
| updated_at | INT NOT NULL | changed | |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_doctors_user ON doctors(user_id);
|
||||
CREATE INDEX idx_doctors_representation ON doctors(representation_id);
|
||||
CREATE INDEX idx_doctors_active ON doctors(active_doctor_appointment);
|
||||
CREATE INDEX idx_doctors_rate ON doctors(doctor_rate DESC);
|
||||
CREATE INDEX idx_doctor_addresses_doctor ON doctor_addresses(doctor_id);
|
||||
CREATE INDEX idx_clinics_user ON clinics(user_id);
|
||||
CREATE UNIQUE INDEX idx_doctor_secretaries_doctor_secretary ON doctor_secretaries(doctor_id, secretary_id);
|
||||
```
|
||||
|
||||
## نمونه داده واقعی از DB (clinic_pro table)
|
||||
```
|
||||
id=18, bundle='doctor'
|
||||
id=22, bundle='doctor'
|
||||
id=23, bundle='clinic'
|
||||
id=24, bundle='clinic'
|
||||
id=27, bundle='doctor_secretary'
|
||||
id=29, bundle='doctor'
|
||||
id=38, bundle='doctors_addresses'
|
||||
id=41, bundle='representation'
|
||||
```
|
||||
|
||||
## روابط کامل clinic_pro entity
|
||||
- `doctors` → `users` (ManyToOne, UNIQUE → OneToOne)
|
||||
- `doctors` → `representations` (ManyToOne)
|
||||
- `doctors` ↔ `categories` (ManyToMany: specialties, services, states, cities)
|
||||
- `doctor_addresses` → `doctors` (ManyToOne, CASCADE)
|
||||
- `clinics` → `users` (ManyToOne)
|
||||
- `clinics` ↔ `doctors` (ManyToMany)
|
||||
- `clinics` ↔ `categories` (ManyToMany)
|
||||
- `doctor_secretaries` → `doctors` (ManyToOne)
|
||||
- `doctor_secretaries` → `users` as secretary (ManyToOne)
|
||||
@@ -0,0 +1,64 @@
|
||||
# نکات پیادهسازی — تسک ۰۵: ماژول دکتر
|
||||
|
||||
## فیلتر لیست دکترها
|
||||
لیست دکترها باید فیلترهای زیر را پشتیبانی کند:
|
||||
```php
|
||||
// DoctorRepository.php
|
||||
public function findFiltered(DoctorFilterRequest $filter): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('d')
|
||||
->join('d.user', 'u');
|
||||
|
||||
if ($filter->specialty) {
|
||||
$qb->andWhere('d.specialty = :specialty')
|
||||
->setParameter('specialty', $filter->specialty);
|
||||
}
|
||||
if ($filter->city) {
|
||||
$qb->join('d.addresses', 'a')
|
||||
->andWhere('a.city = :city')
|
||||
->setParameter('city', $filter->city);
|
||||
}
|
||||
if ($filter->name) {
|
||||
$qb->andWhere('u.firstName LIKE :name OR u.lastName LIKE :name')
|
||||
->setParameter('name', '%'.$filter->name.'%');
|
||||
}
|
||||
if ($filter->insurance) {
|
||||
$qb->andWhere('JSON_CONTAINS(d.insurances, :ins) = 1')
|
||||
->setParameter('ins', json_encode([$filter->insurance]));
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
```
|
||||
|
||||
## آپدیت میانگین امتیاز
|
||||
وقتی یک rating جدید ثبت میشود (تسک ۱۲)، average_rating را آپدیت کن:
|
||||
```php
|
||||
// در RatingService (تسک ۱۲)
|
||||
$this->em->createQuery(
|
||||
'UPDATE Doctor d SET d.averageRating = (
|
||||
SELECT AVG(r.score) FROM Rating r WHERE r.doctor = d
|
||||
), d.reviewCount = (
|
||||
SELECT COUNT(r.id) FROM Rating r WHERE r.doctor = d
|
||||
) WHERE d.id = :id'
|
||||
)->setParameter('id', $doctor->getId())->execute();
|
||||
```
|
||||
|
||||
## آدرس مطب
|
||||
- یک دکتر میتواند چندین آدرس مطب داشته باشد
|
||||
- `{doctorId}` در endpoint لیست آدرسها، UUID دکتر است (نه ID)
|
||||
- دکتر میتواند آدرسهای خودش را ویرایش/حذف کند
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /api/v1/doctor → ROLE_ADMIN
|
||||
PATCH /api/v1/doctor/{uuid} → owner (دکتر خودش) یا ROLE_ADMIN
|
||||
GET /api/v1/doctor/{uuid} → عمومی
|
||||
GET /api/v1/doctors → عمومی
|
||||
|
||||
POST doctor-address → دکتر احراز هویتشده (برای خودش)
|
||||
PATCH doctor-address/{id} → owner یا ROLE_ADMIN
|
||||
DELETE doctor-address/{id} → owner یا ROLE_ADMIN
|
||||
GET doctor-address/{id} → عمومی
|
||||
GET doctor-addresses/{doctorId} → عمومی
|
||||
```
|
||||
@@ -0,0 +1,178 @@
|
||||
# تسک ۰۵: ماژول دکتر
|
||||
|
||||
## توضیح
|
||||
پیادهسازی مدیریت پروفایل دکترها، لیست دکترها با فیلتر،
|
||||
آپلود تصویر پروفایل و مدیریت آدرسهای مطب.
|
||||
|
||||
## Endpoint ها (واقعی از Drupal)
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/doctor` | ایجاد پروفایل دکتر | بله |
|
||||
| PATCH | `/api/v1/doctor/{uuid}` | ویرایش پروفایل دکتر | بله (Owner/Admin) |
|
||||
| DELETE | `/api/v1/doctor/{uuid}` | حذف دکتر | بله (Admin) |
|
||||
| GET | `/api/v1/doctor/{uuid}` | دریافت پروفایل کامل دکتر | خیر |
|
||||
| GET | `/api/v1/doctors` | لیست دکترها با فیلتر | خیر |
|
||||
| POST | `/file/upload/clinic_pro/doctor/field_image` | آپلود تصویر پروفایل دکتر | بله |
|
||||
| GET | `/api/v1/clinic/doctor-list/{clinic_uuid}` | لیست دکترهای یک کلینیک | خیر |
|
||||
| POST | `/api/v1/clinic-pro/doctor-address` | ایجاد آدرس مطب | بله |
|
||||
| PATCH | `/api/v1/clinic-pro/doctor-address/{id}` | ویرایش آدرس | بله |
|
||||
| DELETE | `/api/v1/clinic-pro/doctor-address/{id}` | حذف آدرس | بله |
|
||||
| GET | `/api/v1/clinic-pro/doctor-address/{id}` | دریافت یک آدرس | بله |
|
||||
| GET | `/api/v1/clinic-pro/doctor-addresses/{doctorId}` | لیست آدرسهای دکتر | خیر |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۸ (Categories برای تخصص و سرویسها)
|
||||
|
||||
## زمان تخمینی
|
||||
۸ تا ۱۰ ساعت
|
||||
|
||||
---
|
||||
|
||||
## نمونه واقعی Response — GET /api/v1/doctor/{uuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "29",
|
||||
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||
"name": "single doctor",
|
||||
"gender": "woman",
|
||||
"experience": 21,
|
||||
"activity_time": "1107808200",
|
||||
"medical_system_code": "121212121212",
|
||||
"detail": "test",
|
||||
"degree": "specialist",
|
||||
"specialties": [
|
||||
{
|
||||
"uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40",
|
||||
"id": "603",
|
||||
"name": "داخلی عمومی",
|
||||
"parent": "602"
|
||||
}
|
||||
],
|
||||
"img": [
|
||||
{
|
||||
"url": "https://domain.com/sites/default/files/doctors/2025-11/image.png",
|
||||
"fid": "98",
|
||||
"filename": "image.png",
|
||||
"filemime": "image/png",
|
||||
"filesize": 173665
|
||||
}
|
||||
],
|
||||
"expertise": [
|
||||
{ "uuid": "...", "id": "1601", "name": "معاینه و تشخیص پزشک متخصص" },
|
||||
{ "uuid": "...", "id": "1602", "name": "ویزیت تخصصی" }
|
||||
],
|
||||
"satisfaction": "60",
|
||||
"point": "3.5",
|
||||
"free_turn": "اولین نوبت آزاد: سهشنبه 19 خرداد ساعت 15:00",
|
||||
"hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00",
|
||||
"address": [
|
||||
{
|
||||
"id": "39",
|
||||
"uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e",
|
||||
"name": "مطب اصلی",
|
||||
"map": { "latitude": "53.121212", "longitude": "57.121212" },
|
||||
"address": "آدرس کامل مطب",
|
||||
"telephone": "09120671756"
|
||||
}
|
||||
],
|
||||
"average_rate": { "total_rates": null },
|
||||
"state": [{ "uuid": "...", "id": "23", "name": "کهگیلویه و بویراحمد" }],
|
||||
"city": [{ "uuid": "...", "id": "123", "name": "یاسوج", "parent": "23" }]
|
||||
}
|
||||
```
|
||||
|
||||
> **توجه فیلدها:**
|
||||
> - `img` (نه `image` یا `images`!) — آرایه با url/fid/filename/filemime/filesize
|
||||
> - `specialties` → تخصص اصلی + والد
|
||||
> - `expertise` → همان doctor_services
|
||||
> - `satisfaction` و `point` به صورت **string** برگردانده میشوند
|
||||
> - `free_turn` → متن محاسبهشده از برنامه هفتگی (مثلاً "نوبت آزادی موجود نیست")
|
||||
> - `hours_of_work` → متن محاسبهشده از برنامه هفتگی
|
||||
> - `average_rate.total_rates` → میتواند null باشد
|
||||
> - `experience` → محاسبهشده از `activity_time` (Unix timestamp شروع فعالیت)
|
||||
|
||||
---
|
||||
|
||||
## فیلترهای GET /api/v1/doctors
|
||||
|
||||
| پارامتر | نوع | الزامی | مثال |
|
||||
|---------|-----|--------|------|
|
||||
| `state` | string | بله | `31` |
|
||||
| `city` | string | بله | `62` |
|
||||
| `specialty` | string | خیر | `503` |
|
||||
| `gender` | string | خیر | `man` یا `woman` |
|
||||
| `degree` | string | خیر | `general`, `specialist`, `expert`, `subspecialistplus` |
|
||||
| `name` | string | خیر | `علی` |
|
||||
| `active` | array | خیر | `0` یا `1` |
|
||||
| `page` | string | خیر | `1` |
|
||||
| `limit` | string | خیر | `10` |
|
||||
| `sort` | string | خیر | `ASC` یا `DESC` |
|
||||
|
||||
## نمونه Response لیست دکترها
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "29",
|
||||
"uuid": "...",
|
||||
"name": "single doctor",
|
||||
"gender": "woman",
|
||||
"degree": "specialist",
|
||||
"img": [{ "url": "...", "fid": "98", "filename": "image.png", "filemime": "image/png", "filesize": 173665 }],
|
||||
"specialties": [{ "uuid": "...", "id": "603", "name": "داخلی عمومی", "parent": "602" }],
|
||||
"satisfaction": "60",
|
||||
"point": "3.5",
|
||||
"free_turn": "اولین نوبت آزاد: سهشنبه 19 خرداد ساعت 15:00",
|
||||
"hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00",
|
||||
"active": true
|
||||
}
|
||||
],
|
||||
"page": {
|
||||
"totalRecords": 7,
|
||||
"totalPages": 1,
|
||||
"currentPage": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/v1/doctor/{uuid} — فیلدهای قابل ویرایش
|
||||
```json
|
||||
{
|
||||
"title": "نام دکتر",
|
||||
"doctor_services": ["اکوکاردیوگرافی", "ویزیت تخصصی"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## آپلود تصویر دکتر — POST /file/upload/clinic_pro/doctor/field_image
|
||||
```
|
||||
Headers:
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: file; filename="doctor.png"
|
||||
X-CSRF-Token: {token}
|
||||
Authorization: Bearer {token}
|
||||
Body: binary file content
|
||||
Response → { fid, uuid, ... } که در PATCH doctor استفاده میشود
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نمونه واقعی Response — GET /api/v1/clinic-pro/doctor-address/{id}
|
||||
```json
|
||||
{
|
||||
"id": "39",
|
||||
"uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e",
|
||||
"name": "مطب اصلی",
|
||||
"map": {
|
||||
"latitude": "53.121212",
|
||||
"longitude": "57.121212"
|
||||
},
|
||||
"address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد، برج ماندگار",
|
||||
"telephone": "09120671756"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# معماری — تسک ۰۶: ماژول کلینیک
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Clinic/
|
||||
├── Controller/
|
||||
│ ├── ClinicController.php ← CRUD + لیست
|
||||
│ └── ClinicImageController.php ← آپلود تصویر و لوگو
|
||||
├── Service/
|
||||
│ └── ClinicService.php
|
||||
├── Repository/
|
||||
│ └── ClinicRepository.php
|
||||
├── Entity/
|
||||
│ ├── Clinic.php
|
||||
│ └── ClinicDoctor.php ← رابطه کلینیک-دکتر
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateClinicRequest.php
|
||||
│ │ └── UpdateClinicRequest.php
|
||||
│ └── Response/
|
||||
│ ├── ClinicResponse.php
|
||||
│ └── ClinicDoctorListResponse.php
|
||||
└── Voter/
|
||||
└── ClinicVoter.php
|
||||
```
|
||||
|
||||
## Entity: Clinic
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'clinics')]
|
||||
class Clinic
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $owner;
|
||||
|
||||
#[ORM\Column(length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $description;
|
||||
|
||||
#[ORM\Column(length: 20, nullable: true)]
|
||||
private ?string $phone;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $address;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $city;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imagePath; // تصویر اصلی
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $logoPath; // لوگو
|
||||
|
||||
#[ORM\ManyToMany(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinTable(name: 'clinic_doctors')]
|
||||
private Collection $doctors;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# پایگاه داده — تسک ۰۶: ماژول کلینیک
|
||||
|
||||
## جدول: clinics
|
||||
_(entity_type=clinic_pro, bundle=clinic — از config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | مالک کلینیک |
|
||||
| name | VARCHAR(255) NULL | field_name | نام کلینیک |
|
||||
| info | LONGTEXT NULL | field_info | توضیحات (نه description!) |
|
||||
| address | LONGTEXT NULL | field_address | آدرس |
|
||||
| telephone | VARCHAR(50) NULL | field_telephone | تلفن (نه phone!) |
|
||||
| is_24_7 | TINYINT(1) DEFAULT 0 | field_24_7 | باز ۲۴/۷ |
|
||||
| working_days | VARCHAR(255) NULL | field_working_days | روزهای کاری (متن آزاد، مثال: 'شنبه تا سه شنبه ساعت ۱۲:۲۰') |
|
||||
| latitude | FLOAT NULL | field_latitude | |
|
||||
| longitude | FLOAT NULL | field_longitude | |
|
||||
| logo_id | INT FK → files.id NULL | field_clinic_logo | لوگو کلینیک (image, cardinality=1) |
|
||||
| city_id | INT FK → categories.id NULL | field_city | شهر (entity ref → category/city) |
|
||||
| state_id | INT FK → categories.id NULL | field_state | استان (entity ref → category/state) |
|
||||
| representation_id | INT FK → representations.id NULL | field_agent | نماینده کلینیک (entity ref → user — در Drupal به user اشاره دارد، در Symfony به representation) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## جدول: clinic_doctors (ManyToMany)
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| clinic_id | INT FK → clinics.id CASCADE | | |
|
||||
| doctor_id | INT FK → doctors.id CASCADE | field_doctors | cardinality نامحدود |
|
||||
|
||||
## جدول: clinic_specialties (ManyToMany)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| clinic_id | INT FK → clinics.id CASCADE | |
|
||||
| category_id | INT FK → categories.id | field_clinic_specialty |
|
||||
|
||||
## جدول: clinic_services (ManyToMany)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| clinic_id | INT FK → clinics.id CASCADE | |
|
||||
| category_id | INT FK → categories.id | field_doctor_services |
|
||||
|
||||
## جدول: clinic_insurances (ManyToMany)
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| clinic_id | INT FK → clinics.id CASCADE | |
|
||||
| category_id | INT FK → categories.id | field_insurance (insurance_type یا supplementary_insurance bundle) |
|
||||
|
||||
## جدول: clinic_images (تصاویر اضافی)
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT AUTO_INCREMENT PK | | |
|
||||
| clinic_id | INT FK → clinics.id CASCADE | | |
|
||||
| file_id | INT FK → files.id | field_image_clinic | حداکثر ۵ تصویر (cardinality=5) |
|
||||
| sort_order | INT DEFAULT 0 | delta | ترتیب |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE INDEX idx_clinics_owner ON clinics(user_id);
|
||||
CREATE INDEX idx_clinics_city ON clinics(city_id);
|
||||
CREATE INDEX idx_clinics_state ON clinics(state_id);
|
||||
CREATE INDEX idx_clinics_representation ON clinics(representation_id);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- `field_agent` در Drupal به user اشاره دارد (cardinality=-1 نامحدود) — در Symfony سادهتر شده و فقط یک representation دارد
|
||||
- `field_working_days` متن آزاد است، مثل 'شنبه تا سهشنبه ۸ تا ۱۲'، نه فرمت ساختاریافته
|
||||
- `field_telephone` نه `phone` — اسم فیلد از Drupal config گرفته شده
|
||||
@@ -0,0 +1,24 @@
|
||||
# نکات پیادهسازی — تسک ۰۶: ماژول کلینیک
|
||||
|
||||
## لیست دکترهای کلینیک
|
||||
endpoint `GET /api/v1/clinic/doctor-list/{uuid}` لیست دکترهایی که
|
||||
به این کلینیک تعلق دارند را برمیگرداند. اطلاعات دکتر شامل:
|
||||
- نام، تخصص، تصویر، میانگین امتیاز
|
||||
|
||||
## آپلود دو نوع تصویر
|
||||
کلینیک دو تصویر دارد:
|
||||
- `image`: تصویر اصلی/배너 کلینیک (حداکثر ۵MB)
|
||||
- `logo`: لوگوی کلینیک (حداکثر ۲MB، ترجیحاً مربعی)
|
||||
|
||||
هر دو در مسیر `public/uploads/clinics/` ذخیره میشوند.
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /api/v1/clinic → ROLE_ADMIN
|
||||
GET /api/v1/clinic/{uuid} → عمومی
|
||||
PATCH /api/v1/clinic/{uuid} → owner یا ROLE_ADMIN
|
||||
GET /api/v1/clinics → عمومی
|
||||
GET clinic/doctor-list → عمومی
|
||||
POST clinic/image → owner یا ROLE_ADMIN
|
||||
POST clinic/logo → owner یا ROLE_ADMIN
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
# تسک ۰۶: ماژول کلینیک
|
||||
|
||||
## توضیح
|
||||
پیادهسازی مدیریت کلینیکها، لیست کلینیکها، لیست دکترهای هر کلینیک
|
||||
و آپلود تصویر و لوگوی کلینیک.
|
||||
|
||||
## Endpoint ها (واقعی از Drupal)
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/clinic` | ایجاد کلینیک | بله |
|
||||
| GET | `/api/v1/clinic/{uuid}` | دریافت اطلاعات کامل کلینیک | خیر |
|
||||
| PATCH | `/api/v1/clinic/{uuid}` | ویرایش کلینیک | بله (Owner/Admin) |
|
||||
| GET | `/api/v1/clinics` | لیست کلینیکها با فیلتر | خیر |
|
||||
| GET | `/api/v1/clinic/doctor-list/{clinic_uuid}` | لیست دکترهای کلینیک | خیر |
|
||||
| POST | `/file/upload/clinic_pro/clinic/field_image_clinic` | آپلود تصویر گالری کلینیک | بله |
|
||||
| POST | `/file/upload/clinic_pro/clinic/field_clinic_logo` | آپلود لوگوی کلینیک | بله |
|
||||
|
||||
> **⚠ مسیر آپلود واقعی:** `/file/upload/clinic_pro/clinic/field_image_clinic` (نه `/api/v1/clinic/image`)
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۸ (Categories)
|
||||
|
||||
## زمان تخمینی
|
||||
۶ تا ۸ ساعت
|
||||
|
||||
---
|
||||
|
||||
## فیلترهای GET /api/v1/clinics
|
||||
|
||||
| پارامتر | نوع | الزامی | مثال |
|
||||
|---------|-----|--------|------|
|
||||
| `state` | string | خیر | `13` |
|
||||
| `city` | string | خیر | `32` |
|
||||
| `specialty` | string | خیر | `511` |
|
||||
| `page` | string | بله | `1` |
|
||||
| `limit` | string | بله | `10` |
|
||||
| `sort` | string | خیر | `DESC` |
|
||||
|
||||
---
|
||||
|
||||
## نمونه واقعی Response — GET /api/v1/clinic/{uuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "23",
|
||||
"uuid": "e4550163-5a88-4f67-b07e-cd6063738598",
|
||||
"title": "clinic 1",
|
||||
"images_clinic": [
|
||||
{
|
||||
"url": "https://domain.com/sites/default/files/2025-11/image.png",
|
||||
"fid": "91",
|
||||
"filename": "image.png",
|
||||
"filemime": "image/png",
|
||||
"filesize": 34314
|
||||
}
|
||||
],
|
||||
"clinic_logo": [
|
||||
{
|
||||
"url": "https://domain.com/sites/default/files/clinics/logo/2025-11/logo.png",
|
||||
"fid": "96",
|
||||
"filename": "logo.png",
|
||||
"filemime": "image/png",
|
||||
"filesize": 39281
|
||||
}
|
||||
],
|
||||
"phone_number": "۰۶۱-۳۳۹۱۶۵۸۹",
|
||||
"caption": "توضیحات درباره کلینیک...",
|
||||
"list_bime": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"id": "1559",
|
||||
"name": "بیمه آتیه سازان حافظ",
|
||||
"logo": [{ "url": "...", "fid": "5", "filename": "hafez-insurance.png", "filemime": "image/png", "filesize": 16100 }]
|
||||
}
|
||||
],
|
||||
"specialties": [
|
||||
{ "uuid": "...", "id": "610", "name": "روماتولوژی", "parent": "602" }
|
||||
],
|
||||
"services": [
|
||||
{ "uuid": "...", "id": "1602", "name": "ویزیت تخصصی" }
|
||||
],
|
||||
"clinic_specialty": [
|
||||
{ "uuid": "...", "id": "610", "name": "روماتولوژی", "parent": "602" }
|
||||
],
|
||||
"doctors": 3,
|
||||
"doctor_list": null,
|
||||
"city": [{ "uuid": "...", "id": "130", "name": "بندرعباس", "parent": "29" }],
|
||||
"state": [{ "uuid": "...", "id": "29", "name": "هرمزگان" }],
|
||||
"location": "بندرعباس: رسالت شمالی- میدان صادقیه",
|
||||
"map": { "latitude": "27.200632975404", "longitude": "56.356043815613" },
|
||||
"24_7": true,
|
||||
"field_working_days": "شنبه تا سه شنبه ساعت ۱۲:۲۰"
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠ نکات فیلدهای واقعی Response:**
|
||||
> - **`phone_number`** (نه `telephone`!) — شماره تماس
|
||||
> - **`caption`** (نه `info`!) — توضیحات کلینیک
|
||||
> - **`images_clinic`** (نه `images`!) — تصاویر گالری با url/fid/filename/filemime/filesize
|
||||
> - **`clinic_logo`** (نه `logo`!) — لوگو با url/fid/filename/filemime/filesize
|
||||
> - **`list_bime`** (نه `insurances`!) — بیمههای کلینیک، هر آیتم دارای `logo` نیز هست
|
||||
> - **`field_working_days`** — روزهای کاری (string آزاد)
|
||||
> - **`24_7`** — boolean
|
||||
> - **`doctors`** — تعداد دکترها (integer)
|
||||
> - **`services`** و **`clinic_specialty`** هر دو در response هستند
|
||||
|
||||
---
|
||||
|
||||
## فیلدهای PATCH /api/v1/clinic/{uuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "نام کلینیک",
|
||||
"address": "آدرس",
|
||||
"telephone": "شماره تلفن",
|
||||
"latitude": "27.2",
|
||||
"longitude": "56.3",
|
||||
"insurance": [300, 301],
|
||||
"info": "توضیحات",
|
||||
"doctor_services": [575, 576],
|
||||
"working_days": "شنبه تا سهشنبه",
|
||||
"24_7": 1,
|
||||
"image_clinic": [91, 92],
|
||||
"clinic_logo": [96]
|
||||
}
|
||||
```
|
||||
|
||||
## فیلدهای POST /api/v1/clinic (ایجاد)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "نام کلینیک",
|
||||
"state": [1],
|
||||
"city": [32],
|
||||
"address": "آدرس",
|
||||
"telephone": "شماره تلفن",
|
||||
"latitude": "50.21",
|
||||
"longitude": "57.212",
|
||||
"insurance": [300, 301],
|
||||
"info": "توضیحات",
|
||||
"doctor_services": [575, 576],
|
||||
"working_days": "شنبه تا سهشنبه",
|
||||
"24_7": 1,
|
||||
"image_clinic": [19],
|
||||
"clinic_logo": [20]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## آپلود تصویر/لوگوی کلینیک
|
||||
|
||||
```
|
||||
POST /file/upload/clinic_pro/clinic/field_image_clinic
|
||||
POST /file/upload/clinic_pro/clinic/field_clinic_logo
|
||||
|
||||
Headers:
|
||||
Content-Type: application/octet-stream
|
||||
Content-Disposition: file; filename="clinic.png"
|
||||
X-CSRF-Token: {token}
|
||||
Authorization: Bearer {token}
|
||||
Body: binary file content
|
||||
Response → { fid, ... } که در image_clinic یا clinic_logo استفاده میشود
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# معماری — تسک ۰۸: ماژول دستهبندیها
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Category/
|
||||
├── Controller/
|
||||
│ └── CategoryController.php ← همه endpoint ها
|
||||
├── Service/
|
||||
│ └── CategoryService.php
|
||||
├── Repository/
|
||||
│ └── CategoryRepository.php
|
||||
├── Entity/
|
||||
│ └── Category.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateCategoryRequest.php
|
||||
│ │ └── UpdateCategoryRequest.php
|
||||
│ └── Response/
|
||||
│ └── CategoryResponse.php
|
||||
└── DataFixtures/
|
||||
└── CategoryFixtures.php ← دادههای اولیه (استان، شهر، تخصص، ...)
|
||||
```
|
||||
|
||||
## Entity: Category
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'categories')]
|
||||
class Category
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(length: 200)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $code; // کد انگلیسی برای فیلتر
|
||||
|
||||
// نوع دسته: tag, supplementary_insurance, insurance_type,
|
||||
// state, city, specially_doctor, doctor_services
|
||||
#[ORM\Column(length: 50)]
|
||||
private string $type;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?Category $parent; // برای رابطه استان-شهر
|
||||
|
||||
#[ORM\Column(type: 'integer', default: 0)]
|
||||
private int $sortOrder = 0;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## Routing نمونه
|
||||
```php
|
||||
// GET /api/v1/categorys/{type}
|
||||
#[Route('/api/v1/categorys/{type}', methods: ['GET'])]
|
||||
public function listByType(string $type): Response
|
||||
{
|
||||
$allowed = ['tag', 'supplementary_insurance', 'insurance_type',
|
||||
'state', 'city', 'specially_doctor', 'doctor_services'];
|
||||
if (!in_array($type, $allowed)) {
|
||||
return $this->notFound();
|
||||
}
|
||||
return $this->json($this->categoryService->findByType($type));
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# پایگاه داده — تسک ۰۸: ماژول دستهبندیها
|
||||
|
||||
## ساختار واقعی از DB backup
|
||||
|
||||
جدول `category` یک entity با چند bundle است.
|
||||
بیشتر دادهها (استانها، شهرها، تخصصها) در INSERTهای DB backup موجودند.
|
||||
|
||||
## جدول: categories
|
||||
_(entity_type=category — از DB backup تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| bundle | VARCHAR(32) NOT NULL | bundle | نوع دستهبندی |
|
||||
| label | VARCHAR(255) NULL | label | نام (base field) |
|
||||
| status | TINYINT(1) DEFAULT 1 | status | فعال/غیرفعال |
|
||||
| parent_id | INT FK → categories.id NULL | field_parent | والد (استان→شهر / تخصص والد) |
|
||||
| weight | INT DEFAULT 0 | field_weight | ترتیب نمایش |
|
||||
| logo_id | INT FK → files.id NULL | field_logo | تصویر/آیکون (برای بیمهها) |
|
||||
| title | VARCHAR(255) NULL | field_title | عنوان جایگزین |
|
||||
| representation_id | INT FK → representations.id NULL | field_representation | نماینده مرتبط (برای city bundle) |
|
||||
|
||||
## فیلدهای اختصاصی bundle=city
|
||||
_(فقط برای city bundle — از config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| contact_phone | VARCHAR(255) NULL | field_contactphone | تلفن |
|
||||
| email | VARCHAR(255) NULL | field_email | ایمیل |
|
||||
| description | TEXT NULL | field_description | توضیحات |
|
||||
| slogan | VARCHAR(255) NULL | field_slogan | شعار |
|
||||
| domain | VARCHAR(255) NULL | field_domain | دامنه اختصاصی شهر |
|
||||
| keywords | VARCHAR(255) NULL | field_keywords | کلیدواژه SEO |
|
||||
| footer_description | TEXT NULL | field_footerdescription | توضیحات footer |
|
||||
| footer_disclaimer | TEXT NULL | field_footerdisclaimer | سلب مسئولیت |
|
||||
| social_media | LONGTEXT NULL | field_socialmedia | شبکههای اجتماعی (JSON) |
|
||||
|
||||
## مقادیر مجاز bundle
|
||||
```
|
||||
state → استان (31 استان ایران — داده در DB موجود)
|
||||
city → شهر (parent_id = state_id)
|
||||
specially_doctor → تخصص پزشکی
|
||||
doctor_services → سرویسهای پزشکی
|
||||
insurance_type → نوع بیمه پایه
|
||||
supplementary_insurance → بیمه تکمیلی
|
||||
tag → تگ بلاگ
|
||||
```
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE INDEX idx_categories_bundle ON categories(bundle);
|
||||
CREATE INDEX idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX idx_categories_status ON categories(status, bundle);
|
||||
```
|
||||
|
||||
## دادههای موجود در DB backup (نمونه)
|
||||
```
|
||||
استانها: id=1..100 (bundle='state') — تمام ۳۱ استان ایران
|
||||
شهرها: id=101..2503 (bundle='city') — شهرهای ایران
|
||||
```
|
||||
دادهها باید از DB backup به Symfony fixtures مهاجرت داده شوند.
|
||||
|
||||
## نکته مهم: city bundle
|
||||
City bundle فیلدهای زیادی دارد که برای نمایش اطلاعات سایت نماینده استفاده میشوند:
|
||||
- `field_representation` → لینک به نماینده (multi-tenant)
|
||||
- `field_domain` → دامنه اختصاصی شهر
|
||||
- `field_slogan`, `field_keywords` → SEO
|
||||
این فیلدها در Symfony در جدول جداگانه `city_settings` یا در همان categories با nullable columns نگهداشته میشوند.
|
||||
@@ -0,0 +1,33 @@
|
||||
# نکات پیادهسازی — تسک ۰۸: ماژول دستهبندیها
|
||||
|
||||
## کشینگ
|
||||
لیستهای Lookup (استان، شهر، تخصص) بهندرت تغییر میکنند.
|
||||
→ نتایج را با Symfony Cache (Redis) کش کن:
|
||||
```php
|
||||
public function findByType(string $type): array
|
||||
{
|
||||
return $this->cache->get("categories_{$type}", function (ItemInterface $item) use ($type) {
|
||||
$item->expiresAfter(3600); // 1 ساعت
|
||||
return $this->repository->findBy(['type' => $type], ['sortOrder' => 'ASC']);
|
||||
});
|
||||
}
|
||||
```
|
||||
→ هنگام ایجاد/ویرایش/حذف category، کش مربوط را invalidate کن.
|
||||
|
||||
## فیلتر شهر براساس استان
|
||||
برای لیست شهرها، پارامتر `state_id` اختیاری است:
|
||||
```
|
||||
GET /api/v1/categorys/city?state_id=5
|
||||
```
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
GET /api/v1/categorys/* → عمومی (بدون auth)
|
||||
POST /api/v1/category → ROLE_ADMIN
|
||||
PATCH /api/v1/category/{id} → ROLE_ADMIN
|
||||
DELETE /api/v1/category/{id} → ROLE_ADMIN
|
||||
```
|
||||
|
||||
## نکته نام endpoint
|
||||
در Drupal از `categorys` (اشتباه گرامری) استفاده شده.
|
||||
در Symfony همان مسیر را حفظ کن تا کلاینت تغییر نکند.
|
||||
@@ -0,0 +1,52 @@
|
||||
# تسک ۰۸: ماژول دستهبندیها و Lookup ها
|
||||
|
||||
## توضیح
|
||||
پیادهسازی لیستهای ثابت (lookup) مثل استانها، شهرها، تخصصهای پزشکی،
|
||||
بیمهها، تگها و مدیریت دستهبندیها.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/api/v1/categorys/tag` | لیست تگها | خیر |
|
||||
| GET | `/api/v1/categorys/supplementary_insurance` | بیمههای تکمیلی | خیر |
|
||||
| GET | `/api/v1/categorys/insurance_type` | نوع بیمه پایه | خیر |
|
||||
| GET | `/api/v1/categorys/state` | لیست استانها | خیر |
|
||||
| GET | `/api/v1/categorys/city` | لیست شهرها | خیر |
|
||||
| GET | `/api/v1/categorys/specially_doctor` | تخصصهای پزشکی | خیر |
|
||||
| GET | `/api/v1/categorys/doctor_services` | سرویسهای پزشکی | خیر |
|
||||
| POST | `/api/v1/category` | ایجاد دستهبندی | بله (Admin) |
|
||||
| PATCH | `/api/v1/category/{id}` | ویرایش دستهبندی | بله (Admin) |
|
||||
| DELETE | `/api/v1/category/{id}` | حذف دستهبندی | بله (Admin) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱ و ۰۲
|
||||
|
||||
## نکته مهم
|
||||
این تسک باید **قبل از تسکهای ۰۵، ۰۶** انجام شود چون
|
||||
تسکهای دکتر و کلینیک به categories وابستهاند.
|
||||
|
||||
## زمان تخمینی
|
||||
۴ تا ۵ ساعت
|
||||
|
||||
## نمونه Response
|
||||
|
||||
### GET /api/v1/categorys/state
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "name": "تهران", "code": "tehran" },
|
||||
{ "id": 2, "name": "اصفهان", "code": "isfahan" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/categorys/specially_doctor
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "name": "قلب و عروق", "code": "cardiology" },
|
||||
{ "id": 2, "name": "مغز و اعصاب", "code": "neurology" }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,130 @@
|
||||
# معماری — تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/AppointmentSettings/
|
||||
├── Controller/
|
||||
│ ├── WeeklyScheduleController.php
|
||||
│ ├── DateOverrideController.php
|
||||
│ └── HolidayController.php
|
||||
├── Service/
|
||||
│ ├── WeeklyScheduleService.php
|
||||
│ ├── DateOverrideService.php
|
||||
│ └── HolidayService.php
|
||||
├── Repository/
|
||||
│ ├── WeeklyScheduleRepository.php
|
||||
│ ├── DateOverrideRepository.php
|
||||
│ └── HolidayRepository.php
|
||||
├── Entity/
|
||||
│ ├── WeeklySchedule.php
|
||||
│ ├── DateOverride.php
|
||||
│ └── Holiday.php
|
||||
└── DTO/
|
||||
├── Request/
|
||||
│ ├── CreateWeeklyScheduleRequest.php
|
||||
│ ├── CreateDateOverrideRequest.php
|
||||
│ └── CreateHolidayRequest.php
|
||||
└── Response/
|
||||
├── WeeklyScheduleResponse.php
|
||||
└── DateOverrideResponse.php
|
||||
```
|
||||
|
||||
## Entity: WeeklySchedule
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
class WeeklySchedule
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor;
|
||||
|
||||
// هر روز هفته یک JSON: {active, slots: [{start, end, duration}]}
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $saturday = ['active' => false, 'slots' => []];
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $sunday = ['active' => false, 'slots' => []];
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $monday = ['active' => false, 'slots' => []];
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $tuesday = ['active' => false, 'slots' => []];
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $wednesday = ['active' => false, 'slots' => []];
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $thursday = ['active' => false, 'slots' => []];
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $friday = ['active' => false, 'slots' => []];
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## Entity: DateOverride
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
class DateOverride
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'date')]
|
||||
private \DateTimeInterface $date;
|
||||
|
||||
#[ORM\Column(type: 'boolean', default: false)]
|
||||
private bool $active;
|
||||
|
||||
#[ORM\Column(length: 200, nullable: true)]
|
||||
private ?string $reason;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $customSlots; // [{start, end, duration}]
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## Entity: Holiday
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'holidays')]
|
||||
class Holiday
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'date')]
|
||||
private \DateTimeInterface $startDate;
|
||||
|
||||
#[ORM\Column(type: 'date')]
|
||||
private \DateTimeInterface $endDate;
|
||||
|
||||
#[ORM\Column(length: 200, nullable: true)]
|
||||
private ?string $reason;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,121 @@
|
||||
# پایگاه داده — تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||
|
||||
## مهم: ساختار واقعی field_setting از DB backup
|
||||
|
||||
**تفاوت اساسی با طراحی اولیه:**
|
||||
- یک فیلد JSON به نام `field_setting` کل برنامه هفتگی را ذخیره میکند
|
||||
- ساختار: **آرایه ۷ المان** (ایندکس 0=شنبه تا 6=جمعه)
|
||||
- هر روز دو نوبت **صبح** و **عصر** دارد (نه slotهای آرایهای)
|
||||
|
||||
## جدول: weekly_schedules
|
||||
_(entity_type=appointment_settings, bundle=weekly_schedule)_
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| doctor_id | INT FK → doctors.id UNIQUE | یک رکورد بهازای هر دکتر |
|
||||
| setting | LONGTEXT NOT NULL | JSON برنامه کامل هفتگی |
|
||||
| created_at | INT NOT NULL | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | Unix timestamp |
|
||||
|
||||
## ساختار واقعی JSON فیلد `setting` (از DB backup)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"morning": {
|
||||
"active": 1,
|
||||
"location_id": 48,
|
||||
"start_time": "08:00",
|
||||
"end_time": "12:00",
|
||||
"patient_limit": 10,
|
||||
"duration_per_patient": 15,
|
||||
"has_rest": true,
|
||||
"rest_interval": 60,
|
||||
"time_to_rest": 10
|
||||
},
|
||||
"evening": {
|
||||
"active": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"morning": { "active": 0 },
|
||||
"evening": {
|
||||
"active": 1,
|
||||
"location_id": 49,
|
||||
"start_time": "15:00",
|
||||
"end_time": "18:00",
|
||||
"patient_limit": 8,
|
||||
"duration_per_patient": 20,
|
||||
"has_rest": false
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
ایندکس روزها:
|
||||
| ایندکس | روز |
|
||||
|--------|-----|
|
||||
| 0 | شنبه |
|
||||
| 1 | یکشنبه |
|
||||
| 2 | دوشنبه |
|
||||
| 3 | سهشنبه |
|
||||
| 4 | چهارشنبه |
|
||||
| 5 | پنجشنبه |
|
||||
| 6 | جمعه |
|
||||
|
||||
فیلدهای هر session (morning/evening):
|
||||
| فیلد | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| active | 0/1 | آیا این نوبت فعال است |
|
||||
| location_id | int | ID آدرس مطب (→ doctor_addresses) |
|
||||
| start_time | "HH:MM" | ساعت شروع |
|
||||
| end_time | "HH:MM" | ساعت پایان |
|
||||
| patient_limit | int | حداکثر تعداد بیمار |
|
||||
| duration_per_patient | int (دقیقه) | مدت هر ویزیت |
|
||||
| has_rest | boolean | آیا استراحت دارد |
|
||||
| rest_interval | int (دقیقه) | فاصله استراحت |
|
||||
| time_to_rest | int (دقیقه) | مدت استراحت |
|
||||
|
||||
## جدول: date_overrides
|
||||
_(entity_type=appointment_settings, bundle=date_override)_
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| doctor_id | INT FK → doctors.id | دکتر |
|
||||
| date | INT NOT NULL | تاریخ (Unix timestamp) — field_date |
|
||||
| active | TINYINT(1) DEFAULT 0 | آیا کار میکند — field_active |
|
||||
| setting | LONGTEXT NULL | JSON اسلاتهای سفارشی (همان ساختار field_setting) |
|
||||
| created_at | INT NOT NULL | |
|
||||
| updated_at | INT NOT NULL | |
|
||||
|
||||
## جدول: holidays
|
||||
_(entity_type=appointment_settings, bundle=holidays)_
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| doctor_id | INT FK → doctors.id | دکتر |
|
||||
| start_date | INT NOT NULL | تاریخ شروع (Unix timestamp) |
|
||||
| end_date | INT NOT NULL | تاریخ پایان (Unix timestamp) |
|
||||
| active | TINYINT(1) DEFAULT 1 | field_active |
|
||||
| created_at | INT NOT NULL | |
|
||||
| updated_at | INT NOT NULL | |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_weekly_schedules_doctor ON weekly_schedules(doctor_id);
|
||||
CREATE INDEX idx_date_overrides_doctor_date ON date_overrides(doctor_id, date);
|
||||
CREATE INDEX idx_holidays_doctor_range ON holidays(doctor_id, start_date, end_date);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- **GET /appointment-settings/{uuid}** — uuid دکتر است، نه uuid schedule
|
||||
- `setting[0]` ایندکس 0=شنبه تا 6=جمعه (هفته ایرانی)
|
||||
- هر روز دقیقاً ۲ نوبت (morning و evening) دارد
|
||||
- session غیرفعال فقط `{"active": 0}` است، بقیه فیلدها ندارد
|
||||
@@ -0,0 +1,72 @@
|
||||
# نکات پیادهسازی — تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||
|
||||
## اولویتبندی تنظیمات (از Manual — بخش ۲.۸.۴)
|
||||
هنگام محاسبه اسلاتهای خالی (تسک ۱۰):
|
||||
```
|
||||
1. DateOverride (بالاترین) → اگر Override فعال برای این تاریخ وجود دارد، تعطیلی نادیده گرفته میشود
|
||||
2. Holiday → اگر تاریخ تعطیل است AND override ندارد → روز بسته است
|
||||
3. WeeklySchedule (پایین) → در صورت نبود override و تعطیلی → برنامه هفتگی
|
||||
```
|
||||
|
||||
## UUID در URL endpoint لیست override ها
|
||||
مسیر `GET /api/v1/appointment-settings/date-override/list/{uuid}`
|
||||
→ این `uuid` برابر است با UUID دکتر (نه DateOverride)
|
||||
|
||||
## ساختار واقعی هر session در weekly schedule (از Manual و API request)
|
||||
```json
|
||||
{
|
||||
"active": 1,
|
||||
"number_of_turns": 10, ← تعداد نوبت (نه patient_limit!)
|
||||
"turn_time": 10, ← مدت هر نوبت به دقیقه (نه duration_per_patient!)
|
||||
"location": { "id": 48 }, ← آدرس مطب (object، نه فقط ID)
|
||||
"start_time": "10:00",
|
||||
"end_time": "13:00"
|
||||
}
|
||||
```
|
||||
|
||||
ساختار کامل weekly schedule (7 روز — از "0"=شنبه تا "6"=جمعه):
|
||||
```json
|
||||
{
|
||||
"0": {
|
||||
"morning": { "active": 1, "number_of_turns": 10, "turn_time": 10, "location": {"id": 48}, "start_time": "10:00", "end_time": "13:00" },
|
||||
"evening": { "active": 0 }
|
||||
},
|
||||
"1": { "morning": {"active": 0}, "evening": { "active": 1, ... } },
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**مهم:** در DB backup، فیلدهای `patient_limit` و `duration_per_patient` استفاده شده بود.
|
||||
در API (کلاینت) از `number_of_turns` و `turn_time` استفاده میشود.
|
||||
در Symfony باید هر دو نام را پشتیبانی کنی یا از نامهای API استفاده کنی.
|
||||
|
||||
## مهم: ذخیرهسازی field_setting (از کد واقعی)
|
||||
```php
|
||||
// در Drupal:
|
||||
$normalized['field_setting'] = json_encode($data['setting'][0]);
|
||||
// یعنی اولین المان آرایهای که فرانت میفرستد ذخیره میشود
|
||||
|
||||
// در Symfony هم همین رویکرد:
|
||||
$weeklySchedule->setSetting(json_encode($request->getSetting()[0]));
|
||||
```
|
||||
|
||||
## GET از UUID دکتر (نه UUID schedule)
|
||||
```php
|
||||
// weeklyScheduleService.get($uuid) در Drupal:
|
||||
// 1. ابتدا دکتر با این uuid را پیدا کن → $doctorEntity
|
||||
// 2. سپس schedule با field_doctor_id = $doctorId را پیدا کن
|
||||
|
||||
// در Symfony:
|
||||
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||
```
|
||||
|
||||
## مجوزها
|
||||
تمام endpoint های این ماژول نیاز به احراز هویت دارند:
|
||||
```
|
||||
POST/PATCH/DELETE → دکتر مرتبط (owner) یا ROLE_ADMIN
|
||||
GET → دکتر مرتبط یا ROLE_ADMIN یا منشی دکتر
|
||||
```
|
||||
|
||||
## user_flow
|
||||
جریان کامل در فایل جداگانه user_flow.md توضیح داده شده است.
|
||||
@@ -0,0 +1,66 @@
|
||||
# تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||
|
||||
## توضیح
|
||||
پیادهسازی سیستم تنظیمات نوبتدهی دکتر شامل برنامه هفتگی،
|
||||
override روزهای خاص و تعطیلات.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/appointment-settings/weekly-schedule` | ایجاد برنامه هفتگی | بله (Doctor) |
|
||||
| PATCH | `/api/v1/appointment-settings/weekly-schedule/{uuid}` | ویرایش برنامه | بله |
|
||||
| GET | `/api/v1/appointment-settings/weekly-schedule/{uuid}` | دریافت برنامه | بله |
|
||||
| DELETE | `/api/v1/booking-setting/{uuid}` | حذف تنظیمات | بله |
|
||||
| GET | `/api/v1/appointment-settings/date-override/list/{uuid}` | لیست override ها | بله |
|
||||
| POST | `/api/v1/appointment-settings/date-override` | ایجاد override | بله (Doctor) |
|
||||
| PATCH | `/api/v1/appointment-settings/date-override/{uuid}` | ویرایش override | بله |
|
||||
| DELETE | `/api/v1/appointment-settings/date-override/{uuid}` | حذف override | بله |
|
||||
| GET | `/api/v1/appointment-settings/date-override/{uuid}` | دریافت override | بله |
|
||||
| POST | `/api/v1/appointment-settings/holidays` | ثبت تعطیلات | بله (Doctor) |
|
||||
| PATCH | `/api/v1/appointment-settings/holidays/{uuid}` | ویرایش تعطیلات | بله |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۵ (Doctor)
|
||||
|
||||
## زمان تخمینی
|
||||
۱۰ تا ۱۲ ساعت
|
||||
|
||||
## نمونه Request
|
||||
|
||||
### POST /api/v1/appointment-settings/weekly-schedule
|
||||
```json
|
||||
{
|
||||
"doctor_uuid": "61be915b-...",
|
||||
"schedule": {
|
||||
"saturday": { "active": true, "slots": [{"start": "09:00", "end": "13:00", "duration": 30}] },
|
||||
"sunday": { "active": true, "slots": [{"start": "09:00", "end": "13:00", "duration": 30}] },
|
||||
"monday": { "active": false, "slots": [] },
|
||||
"tuesday": { "active": true, "slots": [{"start": "14:00", "end": "18:00", "duration": 20}] },
|
||||
"wednesday": { "active": false, "slots": [] },
|
||||
"thursday": { "active": true, "slots": [{"start": "09:00", "end": "12:00", "duration": 30}] },
|
||||
"friday": { "active": false, "slots": [] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/v1/appointment-settings/date-override
|
||||
```json
|
||||
{
|
||||
"doctor_uuid": "...",
|
||||
"date": "2024-03-20",
|
||||
"active": false,
|
||||
"reason": "مرخصی",
|
||||
"custom_slots": []
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/v1/appointment-settings/holidays
|
||||
```json
|
||||
{
|
||||
"doctor_uuid": "...",
|
||||
"start_date": "2024-03-20",
|
||||
"end_date": "2024-03-27",
|
||||
"reason": "نوروز"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
# جریان کاربری — تسک ۰۹: تنظیمات نوبتدهی
|
||||
|
||||
## جریان تنظیم اولیه نوبتدهی توسط دکتر
|
||||
|
||||
```
|
||||
دکتر وارد پنل میشود
|
||||
│
|
||||
▼
|
||||
POST /api/v1/appointment-settings/weekly-schedule
|
||||
{ doctor_uuid, schedule: { saturday: {...}, sunday: {...}, ... } }
|
||||
│
|
||||
└─► ذخیره برنامه هفتگی پایه
|
||||
```
|
||||
|
||||
## جریان ثبت مرخصی یا تعطیلات
|
||||
|
||||
```
|
||||
دکتر تعطیلات را ثبت میکند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/appointment-settings/holidays
|
||||
{ doctor_uuid, start_date, end_date, reason }
|
||||
│
|
||||
└─► در بازه تعطیلات، هیچ نوبتی نمیتوان گرفت
|
||||
```
|
||||
|
||||
## جریان override یک روز خاص
|
||||
|
||||
```
|
||||
دکتر میخواهد یک روز خاص را سفارشی کند
|
||||
│
|
||||
├─► غیرفعال کردن یک روز:
|
||||
│ POST /date-override { date: "2024-03-15", active: false }
|
||||
│
|
||||
└─► اسلات سفارشی برای یک روز:
|
||||
POST /date-override {
|
||||
date: "2024-03-15",
|
||||
active: true,
|
||||
custom_slots: [{ start: "10:00", end: "12:00", duration: 20 }]
|
||||
}
|
||||
```
|
||||
|
||||
## الگوریتم محاسبه اسلاتهای خالی (تسک ۱۰ از این استفاده میکند)
|
||||
|
||||
```
|
||||
برای تاریخ درخواستشده:
|
||||
│
|
||||
▼
|
||||
آیا در بازه Holiday است؟
|
||||
بله → نوبت موجود نیست
|
||||
خیر │
|
||||
▼
|
||||
آیا DateOverride برای این تاریخ وجود دارد؟
|
||||
بله → active=false: نوبت موجود نیست
|
||||
active=true: از custom_slots استفاده کن
|
||||
خیر │
|
||||
▼
|
||||
از WeeklySchedule روز هفته مربوطه استفاده کن
|
||||
active=false: نوبت موجود نیست
|
||||
active=true: اسلاتهای slots را محاسبه کن
|
||||
│
|
||||
▼
|
||||
اسلاتهای رزروشده را حذف کن (از جدول appointments)
|
||||
│
|
||||
▼
|
||||
لیست اسلاتهای خالی را برگردان
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# معماری — تسک ۱۰: ماژول نوبتدهی
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Appointment/
|
||||
├── Controller/
|
||||
│ └── AppointmentController.php
|
||||
├── Service/
|
||||
│ ├── AppointmentService.php
|
||||
│ └── SlotCalculatorService.php ← محاسبه اسلاتهای خالی
|
||||
├── Repository/
|
||||
│ └── AppointmentRepository.php
|
||||
├── Entity/
|
||||
│ └── Appointment.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ └── CreateAppointmentRequest.php
|
||||
│ └── Response/
|
||||
│ ├── AppointmentResponse.php
|
||||
│ └── SlotResponse.php
|
||||
└── Voter/
|
||||
└── AppointmentVoter.php
|
||||
```
|
||||
|
||||
## Entity: Appointment
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'appointments')]
|
||||
class Appointment
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $patient;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'date')]
|
||||
private \DateTimeInterface $appointmentDate;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $appointmentTime; // HH:MM
|
||||
|
||||
// pending, confirmed, cancelled, completed
|
||||
#[ORM\Column(length: 20, default: 'pending')]
|
||||
private string $status;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $insuranceType;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $notes;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Payment::class, mappedBy: 'appointment')]
|
||||
private ?Payment $payment;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## SlotCalculatorService
|
||||
این سرویس با استفاده از WeeklySchedule، DateOverride و Holiday
|
||||
اسلاتهای خالی را برای یک دکتر در یک تاریخ مشخص محاسبه میکند:
|
||||
```
|
||||
calculateAvailableSlots(Doctor $doctor, \DateTimeInterface $date): array
|
||||
```
|
||||
@@ -0,0 +1,82 @@
|
||||
# پایگاه داده — تسک ۱۰: ماژول نوبتدهی
|
||||
|
||||
## جدول: appointments
|
||||
_(entity_type=appointment — از DB backup تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| patient_id | INT FK → users.id NOT NULL | uid | بیمار (owner) |
|
||||
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | entity ref → clinic_pro |
|
||||
| address_id | INT FK → doctor_addresses.id NULL | field_address | entity ref → clinic_pro |
|
||||
| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro |
|
||||
| start_time | INT NOT NULL | field_start_time | Unix timestamp (Asia/Tehran) |
|
||||
| end_time | INT NOT NULL | field_end_time | Unix timestamp |
|
||||
| slot | LONGTEXT NULL | field_slot | JSON (ساختار زیر) |
|
||||
| status | VARCHAR(40) DEFAULT 'waiting_for_payment' | field_status | وضعیت |
|
||||
| visited_at | INT NULL | field_visited_at | زمان ویزیت (Unix timestamp) |
|
||||
| info | LONGTEXT NULL | field_info | یادداشت |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## ساختار واقعی JSON فیلد `slot` (از DB backup)
|
||||
```json
|
||||
{
|
||||
"time": "17:00",
|
||||
"status": "available",
|
||||
"start_time_timestamp": 1763472600,
|
||||
"end_time_timestamp": 1763473800,
|
||||
"duration_per_patient": 20,
|
||||
"location_id": 38
|
||||
}
|
||||
```
|
||||
|
||||
## وضعیتهای کامل (field_status) — از config
|
||||
```
|
||||
waiting_for_payment → پیشفرض — منتظر پرداخت
|
||||
reserved → رزروشده (پرداخت انجام شده)
|
||||
auto_cancel_unpaid → لغو خودکار (عدم پرداخت)
|
||||
cancelled_by_patient → لغو توسط بیمار
|
||||
cancelled_by_doctor → لغو توسط دکتر
|
||||
checked_in → بیمار آمده
|
||||
waiting → در صف انتظار
|
||||
in_progress → در حال ویزیت
|
||||
visited → ویزیت تمام شده
|
||||
no_show → غایب
|
||||
postponed → به تعویق افتاده
|
||||
completed → تکمیل شده
|
||||
```
|
||||
|
||||
⚠️ وضعیتهایی که slot را آزاد میکنند (قابل رزرو مجدد):
|
||||
`auto_cancel_unpaid`, `cancelled_by_patient`, `cancelled_by_doctor`
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE INDEX idx_appointments_patient ON appointments(patient_id);
|
||||
CREATE INDEX idx_appointments_doctor ON appointments(doctor_id);
|
||||
CREATE INDEX idx_appointments_doctor_time ON appointments(doctor_id, start_time);
|
||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||
CREATE INDEX idx_appointments_representation ON appointments(representation_id);
|
||||
```
|
||||
|
||||
## نمونه داده واقعی از DB backup
|
||||
```
|
||||
id=1, uuid='617f78af-...', uid=32, doctor_id=29
|
||||
start_time=1763472600, end_time=1763473800
|
||||
slot: {"time":"17:00","status":"available","start_time_timestamp":1763472600,
|
||||
"end_time_timestamp":1763473800,"duration_per_patient":20,"location_id":38}
|
||||
```
|
||||
|
||||
## روابط
|
||||
- `appointments.patient_id` → `users.id`
|
||||
- `appointments.doctor_id` → `doctors.id` (clinic_pro entity)
|
||||
- `appointments.address_id` → `doctor_addresses.id` (clinic_pro entity)
|
||||
- `appointments.representation_id` → `representations.id` (clinic_pro entity)
|
||||
- `appointments` ← `payments.field_reference_id` (OneToOne)
|
||||
|
||||
## نکات مهم
|
||||
- تایمزون: `Asia/Tehran`
|
||||
- `start_time` و `end_time` هر دو Unix timestamp هستند (INT)
|
||||
- `slot.time` ساعت شروع برای نمایش است (HH:MM)
|
||||
- نوبت ابتدا `waiting_for_payment` → بعد پرداخت → `reserved`
|
||||
@@ -0,0 +1,122 @@
|
||||
# نکات پیادهسازی — تسک ۱۰: ماژول نوبتدهی
|
||||
|
||||
## وضعیتهای واقعی نوبت (از Drupal)
|
||||
```
|
||||
waiting_for_payment → وضعیت پیشفرض هنگام ثبت نوبت
|
||||
confirmed → بعد از پرداخت موفق
|
||||
auto_cancel_unpaid → لغو خودکار به دلیل عدم پرداخت
|
||||
cancelled_by_patient → لغو توسط بیمار
|
||||
cancelled_by_doctor → لغو توسط دکتر
|
||||
```
|
||||
⚠️ در طراحی اولیه `pending/cancelled/completed` بود — اینها **اشتباه** بودند.
|
||||
|
||||
## تشخیص نماینده از HTTP Host (Multi-tenant)
|
||||
```php
|
||||
// در AppointmentService.php Drupal:
|
||||
// نماینده از domain_name=host پیدا میشود
|
||||
private function getRepresentation(string $host): ?int {
|
||||
return $this->representationRepo->findByDomainName($host)?->getId();
|
||||
}
|
||||
// در Symfony: از $request->getHost() استفاده کن
|
||||
$host = $request->getSchemeAndHttpHost() . '/'; // e.g. http://yasuj-nobat.localhost:3000/
|
||||
$representation = $this->representationRepo->findByDomainName($host);
|
||||
```
|
||||
|
||||
## فیلدهای واقعی نوبت (از کد Drupal)
|
||||
```
|
||||
field_doctor_id → entity reference به doctor
|
||||
field_start_time → Unix timestamp (Asia/Tehran)
|
||||
field_end_time → Unix timestamp (Asia/Tehran)
|
||||
field_address → entity reference به doctor_address
|
||||
field_slot → JSON: {start_time_timestamp, end_time_timestamp, location_id, start, end, duration}
|
||||
field_representation → entity reference به representation
|
||||
field_status → string (waiting_for_payment, confirmed, ...)
|
||||
field_visited_at → Unix timestamp (بعد از ویزیت)
|
||||
field_info → یادداشت
|
||||
```
|
||||
|
||||
## اعتبارسنجی slot (از کد Drupal)
|
||||
```php
|
||||
// بررسی start_time معتبر بودن (در آینده، نه گذشته)
|
||||
$checkStartTime = $this->isTimestampValid($startTime, 10); // 10 دقیقه حداقل
|
||||
$checkEndTime = $this->isTimestampValid($endTime, 10);
|
||||
|
||||
// بررسی تداخل (conflict check)
|
||||
$unacceptableStatus = ['auto_cancel_unpaid', 'cancelled_by_patient', 'cancelled_by_doctor'];
|
||||
// اگر نوبتی برای همین doctor + slot وجود داشت که status آن در لیست بالا نبود → خطا
|
||||
```
|
||||
|
||||
## جلوگیری از Race Condition
|
||||
از database transaction + pessimistic write lock استفاده کن:
|
||||
```php
|
||||
$this->entityManager->beginTransaction();
|
||||
try {
|
||||
$existing = $this->repo->findConflictingAppointment(
|
||||
$doctorId, $startTime, $endTime,
|
||||
lockMode: LockMode::PESSIMISTIC_WRITE
|
||||
);
|
||||
if ($existing) throw new SlotAlreadyTakenException();
|
||||
|
||||
$appointment = new Appointment(...);
|
||||
$this->entityManager->persist($appointment);
|
||||
$this->entityManager->flush();
|
||||
$this->entityManager->commit();
|
||||
} catch (\Exception $e) {
|
||||
$this->entityManager->rollback();
|
||||
throw $e;
|
||||
}
|
||||
```
|
||||
|
||||
## Response کامل نوبت (از finalizedData Drupal)
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "...",
|
||||
"status": "waiting_for_payment",
|
||||
"start_time": 1716000000,
|
||||
"end_time": 1716001800,
|
||||
"visited_at": null,
|
||||
"info": null,
|
||||
"slot": {
|
||||
"start_time_timestamp": 1716000000,
|
||||
"end_time_timestamp": 1716001800,
|
||||
"location_id": 42,
|
||||
"start": "09:00",
|
||||
"end": "09:30",
|
||||
"duration": 30
|
||||
},
|
||||
"doctor": {
|
||||
"id": 5,
|
||||
"uuid": "...",
|
||||
"name": "دکتر محمدی",
|
||||
"specialty": {"id": 3, "uuid": "...", "name": "متخصص قلب"}
|
||||
},
|
||||
"address": {
|
||||
"id": 42, "uuid": "...", "name": "مطب شیراز",
|
||||
"address": "...", "phone": "071...",
|
||||
"map": {"latitude": 29.6, "longitude": 52.5}
|
||||
},
|
||||
"patient": {
|
||||
"id": 10, "uuid": "...",
|
||||
"name": "علی رضایی",
|
||||
"mobile": "09120000000",
|
||||
"profile": {"id": 8, "uuid": "..."}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## روزهای غیر قابل رزرو
|
||||
endpoint `GET /appointment/not-available/{doctorId}` تاریخهایی را برمیگرداند که در آنها نوبت خالی نیست:
|
||||
- روزهایی در Holiday جای گرفتهاند
|
||||
- روزهایی که DateOverride با active=false دارند
|
||||
- روزهایی که WeeklySchedule آنها active=false است
|
||||
- روزهایی که همه slotهایشان رزرو فعال دارند
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /appointment → احراز هویتشده
|
||||
GET /appointment-slots/{doctorId} → عمومی
|
||||
GET /appointment/not-available/{id} → عمومی
|
||||
GET /appointment/my-appointments/{id} → owner یا ROLE_ADMIN
|
||||
PATCH /appointment/{uuid}/status → ROLE_ADMIN یا دکتر مرتبط
|
||||
```
|
||||
@@ -0,0 +1,284 @@
|
||||
# تسک ۱۰: ماژول نوبتدهی
|
||||
|
||||
## توضیح
|
||||
سیستم رزرو نوبت شامل نمایش اسلاتهای خالی، رزرو نوبت، لغو نوبت،
|
||||
روزهای غیرقابل رزرو و لیست نوبتهای کاربر.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/api/v1/appointment-slots` | اسلاتهای خالی دکتر در تاریخ | خیر |
|
||||
| POST | `/api/v1/appointment` | رزرو نوبت | بله |
|
||||
| GET | `/api/v1/appointment/not-available/{doctorId}` | روزهای غیرقابل رزرو | خیر |
|
||||
| GET | `/api/v1/appointment/my-appointments/{userId}` | نوبتهای من | بله |
|
||||
| PATCH | `/api/v1/appointment/{uuid}/cancel` | لغو نوبت توسط کاربر | بله (Owner) |
|
||||
| PATCH | `/api/v1/appointment/{uuid}/status` | تغییر وضعیت نوبت | بله (Doctor/Secretary/Admin) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۹ (تنظیمات)، ۱۵ (Payment)
|
||||
|
||||
## زمان تخمینی
|
||||
۱۲ تا ۱۵ ساعت
|
||||
|
||||
---
|
||||
|
||||
## Status Machine نوبت
|
||||
|
||||
```
|
||||
[ایجاد نوبت]
|
||||
↓
|
||||
waiting_for_payment ──→ (پرداخت موفق) ──→ reserved
|
||||
↓ ↓
|
||||
(لغو) ┌────────────┤
|
||||
↓ │ │
|
||||
cancelled_by_patient checked_in (لغو دکتر)
|
||||
↓ ↓
|
||||
waiting cancelled_by_doctor
|
||||
↓
|
||||
in_progress
|
||||
↓
|
||||
┌─────────────┴─────────────┐
|
||||
↓ ↓
|
||||
visited no_show
|
||||
↓
|
||||
completed
|
||||
```
|
||||
|
||||
**وضعیتها:**
|
||||
| وضعیت | توضیح | چه کسی تغییر میدهد |
|
||||
|--------|-------|---------------------|
|
||||
| `waiting_for_payment` | منتظر پرداخت | سیستم — بعد از رزرو |
|
||||
| `reserved` | رزرو شده — پرداخت موفق | سیستم — بعد از تأیید پرداخت |
|
||||
| `checked_in` | بیمار به مطب رسیده | منشی/دکتر |
|
||||
| `waiting` | در صف انتظار مطب | منشی/دکتر |
|
||||
| `in_progress` | ویزیت در حال انجام | منشی/دکتر |
|
||||
| `visited` | ویزیت انجام شد | منشی/دکتر |
|
||||
| `no_show` | بیمار نیامد | منشی/دکتر |
|
||||
| `completed` | کامل شد | سیستم |
|
||||
| `cancelled_by_patient` | لغو توسط بیمار | بیمار (Owner) |
|
||||
| `cancelled_by_doctor` | لغو توسط دکتر | دکتر/Admin |
|
||||
| `postponed` | به تعویق افتاده | دکتر/Admin |
|
||||
|
||||
---
|
||||
|
||||
## فلوی کامل رزرو + پرداخت
|
||||
|
||||
```
|
||||
POST /api/v1/appointment
|
||||
↓
|
||||
1. بررسی اسلات: آیا time در آن date خالی است؟
|
||||
2. بررسی holiday/date_override
|
||||
3. ایجاد appointment با status=waiting_for_payment
|
||||
4. بازگشت uuid نوبت به کلاینت
|
||||
↓
|
||||
POST /api/v1/payment (در task-15)
|
||||
{ appointment_uuid: "...", payment_method: "mellat" }
|
||||
↓
|
||||
5. ایجاد payment با status=pending
|
||||
6. دریافت payment_url از درگاه
|
||||
7. redirect کاربر به درگاه
|
||||
↓
|
||||
[Callback از درگاه بانک]
|
||||
↓
|
||||
8. تأیید پرداخت → payments.status = 'received'
|
||||
9. appointments.status = 'reserved'
|
||||
10. واریز کمیسیون به کیف پول نماینده (اگر از دامنه نماینده)
|
||||
```
|
||||
|
||||
**⚠ نکته:** اگر در ۳۰ دقیقه پرداخت نشود → `waiting_for_payment` به `cancelled_by_system` تغییر کند (job)
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/appointment-slots
|
||||
|
||||
```
|
||||
Query params:
|
||||
doctor_uuid (الزامی)
|
||||
date (الزامی) — فرمت: YYYY-MM-DD
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"date": "2024-03-20",
|
||||
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||
"slots": [
|
||||
{ "time": "09:00", "available": true, "duration": 30 },
|
||||
{ "time": "09:30", "available": false, "duration": 30 },
|
||||
{ "time": "10:00", "available": true, "duration": 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**منطق محاسبه اسلاتهای خالی:**
|
||||
```
|
||||
1. بارگذاری weekly_schedule دکتر برای روز هفته مربوطه
|
||||
2. بررسی date_override برای تاریخ مشخص
|
||||
3. بررسی holiday (اگر تاریخ در بازه تعطیلی است → همه اسلاتها unavailable)
|
||||
4. خواندن نوبتهای موجود با status ≠ cancelled → آن اسلاتها unavailable
|
||||
5. بازگشت لیست اسلاتها با وضعیت available/unavailable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/appointment
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"doctor_uuid": "61be915b-...",
|
||||
"date": "2024-03-20",
|
||||
"time": "09:00",
|
||||
"address_id": 39,
|
||||
"insurance_type_id": null,
|
||||
"notes": "درد معده دارم"
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||
"date": "2024-03-20",
|
||||
"time": "09:00",
|
||||
"status": "waiting_for_payment",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
}
|
||||
|
||||
// Response 409 — اسلات گرفته شده
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_APPOINTMENT_001", "message": "اسلات انتخابشده در دسترس نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/v1/appointment/{uuid}/cancel — لغو نوبت
|
||||
|
||||
```json
|
||||
// Request
|
||||
{ "reason": "به دلیل بیماری نمیتوانم بیایم" }
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "cancelled_by_patient",
|
||||
"refund_status": "pending"
|
||||
}
|
||||
}
|
||||
|
||||
// Response 400 — نوبت قابل لغو نیست
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_APPOINTMENT_002", "message": "نوبت در وضعیت فعلی قابل لغو نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
**قوانین لغو:**
|
||||
- فقط نوبتهای با status `waiting_for_payment` یا `reserved` قابل لغو هستند
|
||||
- اگر پرداخت شده (`reserved`) → `payments.status = 'refund'` و refund شروع میشود
|
||||
- لغو بعد از `checked_in` فقط توسط Admin/Doctor مجاز است
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/v1/appointment/{uuid}/status
|
||||
|
||||
```json
|
||||
// Request (Doctor/Secretary/Admin)
|
||||
{ "status": "checked_in" }
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "checked_in",
|
||||
"updated_at": 1748000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Transition های مجاز:**
|
||||
```
|
||||
reserved → checked_in (Doctor/Secretary)
|
||||
checked_in → waiting (Doctor/Secretary)
|
||||
waiting → in_progress (Doctor/Secretary)
|
||||
in_progress → visited (Doctor/Secretary)
|
||||
in_progress → no_show (Doctor/Secretary)
|
||||
visited → completed (System/Doctor)
|
||||
reserved → cancelled_by_doctor (Doctor/Admin)
|
||||
reserved → postponed (Doctor/Admin)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/appointment/not-available/{doctorId}
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"not_available_dates": [
|
||||
"2024-03-20",
|
||||
"2024-03-21",
|
||||
"2024-04-01"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
- روزهایی که holiday هستند
|
||||
- روزهایی که date_override با `active=false` تعریف شده
|
||||
- روزهایی که همه اسلاتها پر هستند
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/appointment/my-appointments/{userId}
|
||||
|
||||
```
|
||||
Query params:
|
||||
status (اختیاری) — فیلتر بر اساس وضعیت
|
||||
page (اختیاری، پیشفرض 1)
|
||||
limit (اختیاری، پیشفرض 10)
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"doctor": {
|
||||
"uuid": "...",
|
||||
"name": "دکتر احمدی",
|
||||
"specialty": "قلب و عروق",
|
||||
"img": [{ "url": "..." }]
|
||||
},
|
||||
"date": "2024-03-20",
|
||||
"time": "09:00",
|
||||
"status": "reserved",
|
||||
"payment_status": "received",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **Optimistic Locking:** هنگام رزرو اسلات، از Transaction + Lock استفاده شود تا race condition نباشد
|
||||
- **Expiry Job:** نوبتهای `waiting_for_payment` بعد از ۳۰ دقیقه باید auto-cancel شوند (Symfony Scheduler)
|
||||
- **N+1 Prevention:** در لیست نوبتها، دکتر و وضعیت پرداخت با eager loading بارگذاری شوند
|
||||
- **Timestamps:** همه تاریخ/زمانها Unix timestamp (INT) ذخیره میشوند
|
||||
@@ -0,0 +1,48 @@
|
||||
# جریان کاربری — تسک ۱۰: نوبتدهی
|
||||
|
||||
## جریان کامل رزرو نوبت
|
||||
|
||||
```
|
||||
کاربر دکتر را انتخاب میکند
|
||||
│
|
||||
▼
|
||||
GET /api/v1/appointment/not-available/{doctorId}
|
||||
→ دریافت تاریخهای غیر قابل رزرو (برای کالندار)
|
||||
│
|
||||
▼
|
||||
کاربر تاریخ مورد نظر را انتخاب میکند
|
||||
│
|
||||
▼
|
||||
GET /api/v1/appointment-slots?doctor_uuid=...&date=...
|
||||
→ دریافت اسلاتهای خالی آن روز
|
||||
│
|
||||
▼
|
||||
کاربر ساعت مورد نظر را انتخاب میکند
|
||||
│
|
||||
▼
|
||||
POST /api/v1/appointment
|
||||
{ doctor_uuid, date, time, insurance_type, notes }
|
||||
│
|
||||
├─► بررسی موجود بودن اسلات
|
||||
├─► ایجاد appointment با status=pending
|
||||
└─► ایجاد payment با status=pending (تسک ۱۵)
|
||||
│
|
||||
▼
|
||||
کاربر به درگاه پرداخت هدایت میشود (تسک ۱۵)
|
||||
│
|
||||
▼
|
||||
بعد از پرداخت موفق:
|
||||
appointment.status = confirmed
|
||||
payment.status = paid
|
||||
│
|
||||
▼
|
||||
ارسال پیامک تأیید به کاربر و دکتر
|
||||
```
|
||||
|
||||
## جریان مشاهده نوبتهای من
|
||||
|
||||
```
|
||||
GET /api/v1/appointment/my-appointments/{userId}
|
||||
→ لیست همه نوبتها (گذشته و آینده)
|
||||
→ به صورت صعودی بر اساس تاریخ مرتبشده
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# معماری — تسک ۱۱: ماژول بیمه
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Insurance/
|
||||
├── Controller/
|
||||
│ └── InsuranceController.php
|
||||
├── Service/
|
||||
│ └── InsuranceService.php
|
||||
├── Repository/
|
||||
│ └── InsuranceRepository.php
|
||||
├── Entity/
|
||||
│ └── Insurance.php
|
||||
└── DTO/
|
||||
├── Request/
|
||||
│ ├── CreateInsuranceRequest.php
|
||||
│ └── UpdateInsuranceRequest.php
|
||||
└── Response/
|
||||
└── InsuranceResponse.php
|
||||
```
|
||||
|
||||
## Entity: Insurance
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_insurances')]
|
||||
class Insurance
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private Category $insuranceCategory; // از جدول categories
|
||||
|
||||
#[ORM\Column(type: 'boolean', default: true)]
|
||||
private bool $isActive = true;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# پایگاه داده — تسک ۱۱: ماژول بیمه
|
||||
|
||||
## توضیح
|
||||
در Drupal، بیمهها به عنوان دستهبندی (`category` entity) ذخیره میشوند:
|
||||
- bundle=`insurance_type` → بیمههای پایه
|
||||
- bundle=`supplementary_insurance` → بیمههای تکمیلی
|
||||
|
||||
رابطه doctor-insurance از طریق `field_insurance` روی bundle=clinic در Drupal موجود است (نه مستقیم روی doctor).
|
||||
در profile کاربر: `field_basic_insurance` و `field_supplementary_insurance` (entity ref → category).
|
||||
|
||||
## جدول: doctor_insurances (رابطه doctor ↔ insurance)
|
||||
_(از endpoint واقعی: PATCH /api/v1/insurance/1 دارای field_price است)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT AUTO_INCREMENT PK | id | |
|
||||
| doctor_id | INT FK → doctors.id CASCADE | field_doctor | دکتر |
|
||||
| category_id | INT FK → categories.id | field_insurance_type | نوع بیمه (bundle=insurance_type یا supplementary_insurance) |
|
||||
| price | INT NULL | field_price | مبلغ ویزیت با این بیمه (تومان — اختیاری) |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_doctor_insurance ON doctor_insurances(doctor_id, category_id);
|
||||
CREATE INDEX idx_doctor_insurance_cat ON doctor_insurances(category_id);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- دادههای بیمه در جدول `categories` ذخیره میشوند — نه جدول جداگانه
|
||||
- `clinic_insurances` هم وجود دارد: رابطه clinic ↔ insurance (تسک ۰۶)
|
||||
- بیمه کاربر در `profiles` ذخیره میشود: field_basic_insurance, field_supplementary_insurance (تسک ۰۳)
|
||||
- هیچ timestamp در این pivot table لازم نیست
|
||||
@@ -0,0 +1,17 @@
|
||||
# نکات پیادهسازی — تسک ۱۱: ماژول بیمه
|
||||
|
||||
## رابطه با Categories
|
||||
این ماژول از جدول `categories` برای نام و نوع بیمه استفاده میکند.
|
||||
هنگام create، فقط `insurance_category_id` کافی است.
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST → دکتر (برای خودش) یا ROLE_ADMIN
|
||||
GET → دکتر مرتبط یا ROLE_ADMIN
|
||||
PATCH → دکتر مرتبط یا ROLE_ADMIN
|
||||
DELETE → دکتر مرتبط یا ROLE_ADMIN
|
||||
```
|
||||
|
||||
## یکپارچگی با لیست دکتر
|
||||
در endpoint `GET /api/v1/doctors`، بیمههای هر دکتر باید
|
||||
به عنوان فیلد در response ظاهر شوند.
|
||||
@@ -0,0 +1,24 @@
|
||||
# تسک ۱۱: ماژول بیمه
|
||||
|
||||
## توضیح
|
||||
مدیریت بیمههای مرتبط با دکتر یا کلینیک (بیمههایی که دکتر میپذیرد).
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/insurance/` | ایجاد رابطه بیمه | بله (Doctor/Admin) |
|
||||
| GET | `/api/v1/insurance/{id}` | دریافت اطلاعات بیمه | بله |
|
||||
| PATCH | `/api/v1/insurance/{id}` | ویرایش بیمه | بله |
|
||||
| DELETE | `/api/v1/insurance/{id}` | حذف بیمه | بله |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۸ (Categories)
|
||||
|
||||
## زمان تخمینی
|
||||
۳ تا ۴ ساعت
|
||||
|
||||
## توضیح
|
||||
این ماژول مشخص میکند که یک دکتر کدام بیمهها را میپذیرد.
|
||||
دادههای اصلی بیمه در ماژول Categories هستند (تسک ۰۸).
|
||||
این جدول رابطه دکتر ↔ بیمه را ذخیره میکند.
|
||||
@@ -0,0 +1,89 @@
|
||||
# معماری — تسک ۱۲: ماژول امتیاز و نظرات
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Rating/
|
||||
├── Controller/
|
||||
│ ├── RatingController.php
|
||||
│ └── CommentController.php
|
||||
├── Service/
|
||||
│ ├── RatingService.php ← آپدیت average_rating دکتر
|
||||
│ └── CommentService.php
|
||||
├── Repository/
|
||||
│ ├── RatingRepository.php
|
||||
│ └── CommentRepository.php
|
||||
├── Entity/
|
||||
│ ├── Rating.php
|
||||
│ └── Comment.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateRatingRequest.php
|
||||
│ │ ├── CreateCommentRequest.php
|
||||
│ │ └── ConfirmCommentRequest.php
|
||||
│ └── Response/
|
||||
│ ├── RatingResponse.php
|
||||
│ └── CommentResponse.php
|
||||
└── Voter/
|
||||
├── RatingVoter.php
|
||||
└── CommentVoter.php
|
||||
```
|
||||
|
||||
## Entity: Rating
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'ratings')]
|
||||
class Rating
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $patient;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $score; // 1 تا 5
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Appointment::class, nullable: true)]
|
||||
private ?Appointment $appointment;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
|
||||
## Entity: Comment
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'comments')]
|
||||
class Comment
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $author;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $text;
|
||||
|
||||
// pending, approved, rejected
|
||||
#[ORM\Column(length: 20, default: 'pending')]
|
||||
private string $status;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Rating::class, nullable: true)]
|
||||
private ?Rating $rating;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# پایگاه داده — تسک ۱۲: ماژول امتیاز و نظرات
|
||||
|
||||
## مهم: نام فیلد از DB
|
||||
فیلد ستاره در Drupal **`field_starts`** است (نه `field_stars`!) — از config تأیید شد.
|
||||
|
||||
## جدول: ratings
|
||||
_(entity_type=clinic_pro_comment, bundle=rate)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | امتیازدهنده |
|
||||
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | entity ref → clinic_pro |
|
||||
| doctor_behavior | INT NOT NULL | field_doctor_behavior | برخورد مناسب (0-100) |
|
||||
| accuracy_of_diagnosis | INT NOT NULL | field_accuracy_of_diagnosis | تشخیص درست (0-100) |
|
||||
| waiting_time_at_clinic | INT NOT NULL | field_waiting_time_at_clinic | زمان انتظار (0-100) |
|
||||
| doctor_expertise | INT NOT NULL | field_doctor_expertise | مهارت (0-100) |
|
||||
| clinic_cleanliness | INT NOT NULL | field_clinic_cleanliness | نظافت (0-100) |
|
||||
| starts | DECIMAL(10,2) NOT NULL | field_starts | ستاره محاسبهشده (0-5) — ⚠️ `starts` نه `stars`! |
|
||||
| percent | FLOAT NOT NULL | field_percent | درصد محاسبهشده (0-100) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## جدول: comments
|
||||
_(entity_type=clinic_pro_comment, bundle=comments)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | نویسنده |
|
||||
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | دکتر |
|
||||
| comment | LONGTEXT NOT NULL | field_comment | متن نظر |
|
||||
| approved | TINYINT(1) DEFAULT 0 | field_approved | تأیید شده (نه ENUM بلکه boolean) |
|
||||
| parent_id | INT FK → comments.id NULL | field_parent | نظر پدر (پاسخ به نظر) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## جدول: likes
|
||||
_(entity_type=clinic_pro_comment, bundle=like)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | کاربر |
|
||||
| comment_id | INT FK → comments.id NOT NULL | field_comment_id | نظر مورد لایک |
|
||||
| is_like | TINYINT(1) NOT NULL | field_like | لایک (1) یا دیسلایک (0) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
-- هر کاربر فقط یک امتیاز برای هر دکتر
|
||||
CREATE UNIQUE INDEX idx_ratings_user_doctor ON ratings(user_id, doctor_id);
|
||||
|
||||
-- هر کاربر فقط یک لایک/دیسلایک برای هر نظر
|
||||
CREATE UNIQUE INDEX idx_likes_user_comment ON likes(user_id, comment_id);
|
||||
|
||||
CREATE INDEX idx_ratings_doctor ON ratings(doctor_id);
|
||||
CREATE INDEX idx_comments_doctor_approved ON comments(doctor_id, approved);
|
||||
CREATE INDEX idx_comments_parent ON comments(parent_id);
|
||||
```
|
||||
|
||||
## نکتههای مهم
|
||||
- فیلد ستاره `starts` است (نه `stars`) — همانطور که در config تأیید شد
|
||||
- `approved` boolean است (0/1)، نه ENUM
|
||||
- `percent` نوع FLOAT است (نه DECIMAL) — از config تأیید شد
|
||||
- `starts` نوع DECIMAL(10,2) است — از config تأیید شد
|
||||
- `comment.status` در جدول پایه Drupal وجود دارد (tinyint) اما از `field_approved` استفاده میشود
|
||||
@@ -0,0 +1,164 @@
|
||||
# نکات پیادهسازی — تسک ۱۲: ماژول امتیاز و نظرات
|
||||
|
||||
## ⚠ تناقض نام فیلدها: API vs DB (بسیار مهم!)
|
||||
|
||||
فیلدهایی که **کلاینت ارسال میکند** با نام فیلدهای **پایگاه داده** متفاوت هستند:
|
||||
|
||||
| نام در Request (API) | نام در DB (Drupal field) | توضیح |
|
||||
|---------------------|------------------------|-------|
|
||||
| `correct_diagnosis` | `accuracy_of_diagnosis` | دقت تشخیص |
|
||||
| `doctor_skill` | `doctor_expertise` | مهارت پزشک |
|
||||
| `behavior_doctor` | `doctor_behavior` | برخورد پزشک |
|
||||
| `office_cleaning` | `clinic_cleanliness` | نظافت مطب |
|
||||
| `time_in_office` | `waiting_time_at_clinic` | زمان انتظار |
|
||||
| `doctor` | `doctor_id` | شناسه دکتر (integer) |
|
||||
| `rate` | `starts` | امتیاز ستارهای (DECIMAL 10,2) |
|
||||
|
||||
**در Symfony باید:**
|
||||
- ورودی را با نامهای API دریافت کن (`correct_diagnosis`, ...)
|
||||
- در Entity و DB با نامهای Drupal ذخیره کن (`accuracy_of_diagnosis`, ...)
|
||||
|
||||
## نمونه واقعی Request — POST /api/v1/clinicpro/rate
|
||||
```json
|
||||
{
|
||||
"correct_diagnosis": 100,
|
||||
"doctor_skill": 100,
|
||||
"behavior_doctor": 100,
|
||||
"office_cleaning": 100,
|
||||
"time_in_office": 100,
|
||||
"doctor": 1,
|
||||
"rate": 2
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه واقعی Request — PATCH /api/v1/clinicpro/rate/{uuid}
|
||||
```json
|
||||
{
|
||||
"correct_diagnosis": 50,
|
||||
"doctor_skill": 60,
|
||||
"behavior_doctor": 70,
|
||||
"office_cleaning": 80,
|
||||
"time_in_office": 90,
|
||||
"doctor": 1,
|
||||
"rate": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## سیستم امتیازدهی وزنی
|
||||
|
||||
Rating در Drupal **5 معیار جداگانه** دارد که هر کدام مقدار 0-100 میگیرند
|
||||
و با وزنهای متفاوت محاسبه میشوند:
|
||||
|
||||
```php
|
||||
$weights = [
|
||||
"doctor_behavior" => 1.5, // behavior_doctor در API
|
||||
"accuracy_of_diagnosis" => 3.0, // correct_diagnosis در API
|
||||
"waiting_time_at_clinic" => 1.0, // time_in_office در API
|
||||
"doctor_expertise" => 2.0, // doctor_skill در API
|
||||
"clinic_cleanliness" => 1.0, // office_cleaning در API
|
||||
];
|
||||
|
||||
// فرمول محاسبه:
|
||||
$weightedAverage = SUM(value * weight) / SUM(weights); // از 100
|
||||
$stars = ($weightedAverage / 100) * 5; // از 5
|
||||
```
|
||||
|
||||
### پیادهسازی calculateDoctorRating در Symfony
|
||||
```php
|
||||
public function calculateRating(array $apiScores): array
|
||||
{
|
||||
// نگاشت نامهای API به نامهای DB
|
||||
$mapped = [
|
||||
'accuracy_of_diagnosis' => $apiScores['correct_diagnosis'] ?? 0,
|
||||
'doctor_expertise' => $apiScores['doctor_skill'] ?? 0,
|
||||
'doctor_behavior' => $apiScores['behavior_doctor'] ?? 0,
|
||||
'clinic_cleanliness' => $apiScores['office_cleaning'] ?? 0,
|
||||
'waiting_time_at_clinic' => $apiScores['time_in_office'] ?? 0,
|
||||
];
|
||||
|
||||
$weights = [
|
||||
'doctor_behavior' => 1.5,
|
||||
'accuracy_of_diagnosis' => 3.0,
|
||||
'waiting_time_at_clinic' => 1.0,
|
||||
'doctor_expertise' => 2.0,
|
||||
'clinic_cleanliness' => 1.0,
|
||||
];
|
||||
|
||||
$totalScore = 0.0;
|
||||
$totalWeight = 0.0;
|
||||
|
||||
foreach ($weights as $key => $weight) {
|
||||
$totalScore += $mapped[$key] * $weight;
|
||||
$totalWeight += $weight;
|
||||
}
|
||||
|
||||
$weightedAverage = $totalWeight > 0 ? $totalScore / $totalWeight : 0;
|
||||
$stars = ($weightedAverage / 100) * 5;
|
||||
|
||||
return [
|
||||
'percent' => round($weightedAverage, 1),
|
||||
'starts' => round(min(5.0, max(0.0, $stars)), 2),
|
||||
// ⚠️ نام فیلد DB: "starts" است نه "stars"!
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## آمار دکتر — GET /api/v1/clinicpro-comment/doctor-rate/{doctorUuid}
|
||||
|
||||
```
|
||||
URL: /api/v1/clinicpro-comment/doctor-rate/{uuid_دکتر}
|
||||
Auth: عمومی (بدون احراز هویت)
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"average_stars": 4.3,
|
||||
"total_rates": 87,
|
||||
"averages": {
|
||||
"average_doctor_behavior": 82.1,
|
||||
"average_accuracy_of_diagnosis": 88.5,
|
||||
"average_waiting_time_at_clinic": 65.3,
|
||||
"average_doctor_expertise": 90.2,
|
||||
"average_clinic_cleanliness": 78.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## تأیید نظرات
|
||||
نظرات با `approved=0` ذخیره میشوند.
|
||||
ادمین آنها را از `GET /api/v1/clinicpro/unverified-comments/{doctorId}` میبیند.
|
||||
سپس با `PATCH /api/v1/clinicpro/unverified-comments/{commentId}` تأیید میکند.
|
||||
|
||||
---
|
||||
|
||||
## اعتبارسنجی مقادیر Rating
|
||||
هر معیار باید بین 0 تا 100 باشد:
|
||||
```php
|
||||
#[Assert\Range(min: 0, max: 100)]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /api/v1/clinicpro/rate → احراز هویتشده
|
||||
PATCH /api/v1/clinicpro/rate/{uuid} → owner (هر کاربر فقط یک امتیاز برای هر دکتر)
|
||||
DELETE /api/v1/rate/doctor/{uuid} → ROLE_ADMIN
|
||||
GET /api/v1/clinicpro/rate/{uuid} → owner (امتیاز کاربر برای دکتر مشخص)
|
||||
GET /api/v1/clinicpro-comment/doctor-rate/{uuid} → عمومی (آمار کلی دکتر)
|
||||
POST /api/v1/clinicpro/comment → احراز هویتشده
|
||||
PATCH /api/v1/clinicpro/comment/{uuid} → owner یا ROLE_ADMIN
|
||||
DELETE /api/v1/clinicpro/comment/{uuid} → owner یا ROLE_ADMIN
|
||||
GET /api/v1/clinicpro/comment/{uuid} → احراز هویتشده
|
||||
GET /api/v1/clinicpro/comments/{doctorId} → احراز هویتشده (فقط approved)
|
||||
GET /api/v1/clinicpro/unverified-comments/{doctorId} → ROLE_ADMIN
|
||||
PATCH /api/v1/clinicpro/unverified-comments/{id} → ROLE_ADMIN (تأیید/رد نظر)
|
||||
POST /api/v1/clinicpro/like → احراز هویتشده
|
||||
PATCH /api/v1/clinicpro/like/{uuid} → owner
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# تسک ۱۲: ماژول امتیاز و نظرات
|
||||
|
||||
## توضیح
|
||||
سیستم امتیازدهی (rate) و نظرات (comment) و لایک کاربران برای دکترها،
|
||||
شامل تأیید نظرات توسط ادمین.
|
||||
|
||||
## Endpoint ها (واقعی از Drupal)
|
||||
|
||||
### امتیازدهی (Rate)
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/clinicpro/rate` | ثبت امتیاز جدید | بله |
|
||||
| PATCH | `/api/v1/clinicpro/rate/{uuid}` | ویرایش امتیاز | بله (Owner) |
|
||||
| DELETE | `/api/v1/rate/doctor/{uuid}` | حذف امتیاز | بله (Admin) |
|
||||
| GET | `/api/v1/clinicpro/rate/{uuid}` | امتیاز من برای دکتر | بله |
|
||||
| GET | `/api/v1/clinicpro-comment/doctor-rate/{doctor_uuid}` | آمار کلی امتیازهای دکتر | خیر |
|
||||
|
||||
### نظرات (Comment)
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/clinicpro/comment` | ثبت نظر جدید | بله |
|
||||
| PATCH | `/api/v1/clinicpro/comment/{uuid}` | ویرایش نظر | بله (Owner/Admin) |
|
||||
| DELETE | `/api/v1/clinicpro/comment/{uuid}` | حذف نظر | بله (Owner/Admin) |
|
||||
| GET | `/api/v1/clinicpro/comment/{uuid}` | دریافت یک نظر | بله |
|
||||
| GET | `/api/v1/clinicpro/comments/{doctorId}` | لیست نظرات دکتر (با page/limit) | بله |
|
||||
| GET | `/api/v1/clinicpro/unverified-comments/{doctorId}` | نظرات تأییدنشده (با page/limit) | بله (Admin) |
|
||||
| PATCH | `/api/v1/clinicpro/unverified-comments/{commentId}` | تأیید/رد نظر | بله (Admin) |
|
||||
|
||||
### لایک (Like)
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/clinicpro/like` | ثبت لایک/دیسلایک | بله |
|
||||
| PATCH | `/api/v1/clinicpro/like/{uuid}` | ویرایش لایک | بله (Owner) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۵ (Doctor)
|
||||
|
||||
## زمان تخمینی
|
||||
۸ تا ۱۰ ساعت
|
||||
|
||||
---
|
||||
|
||||
## ⚠ نام فیلدهای Request (متفاوت از DB!)
|
||||
|
||||
| فیلد در Request | معادل در DB | توضیح |
|
||||
|----------------|------------|-------|
|
||||
| `correct_diagnosis` | `accuracy_of_diagnosis` | دقت تشخیص (0-100) |
|
||||
| `doctor_skill` | `doctor_expertise` | مهارت پزشک (0-100) |
|
||||
| `behavior_doctor` | `doctor_behavior` | برخورد پزشک (0-100) |
|
||||
| `office_cleaning` | `clinic_cleanliness` | نظافت مطب (0-100) |
|
||||
| `time_in_office` | `waiting_time_at_clinic` | زمان انتظار (0-100) |
|
||||
| `doctor` | `doctor_id` | شناسه دکتر |
|
||||
| `rate` | `starts` | امتیاز ستاره (ذخیره محاسبهشده) |
|
||||
|
||||
---
|
||||
|
||||
## نمونه واقعی Request — POST /api/v1/clinicpro/rate
|
||||
```json
|
||||
{
|
||||
"correct_diagnosis": 100,
|
||||
"doctor_skill": 100,
|
||||
"behavior_doctor": 100,
|
||||
"office_cleaning": 100,
|
||||
"time_in_office": 100,
|
||||
"doctor": 1,
|
||||
"rate": 2
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه واقعی Request — PATCH /api/v1/clinicpro/rate/{uuid}
|
||||
```json
|
||||
{
|
||||
"correct_diagnosis": 50,
|
||||
"doctor_skill": 60,
|
||||
"behavior_doctor": 70,
|
||||
"office_cleaning": 80,
|
||||
"time_in_office": 90,
|
||||
"doctor": 1,
|
||||
"rate": 2
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه واقعی Request — PATCH /api/v1/clinicpro/unverified-comments/{uuid}
|
||||
_(تأیید نظر — بدنه خالی یا فقط `approved`)_
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
- هر کاربر فقط **یک امتیاز** برای هر دکتر میتواند ثبت کند (UNIQUE user_id + doctor_id)
|
||||
- نظرات با `approved=0` ذخیره میشوند و باید توسط ادمین تأیید شوند
|
||||
- لایک فقط برای **نظرات** است (نه بلاگ یا دکتر)
|
||||
- هر کاربر فقط **یک لایک** برای هر نظر میتواند ثبت کند
|
||||
- URL کامنتهای تأییدنشده: `{doctorId}` در URL است اما فقط ROLE_ADMIN بررسی میشود
|
||||
@@ -0,0 +1,46 @@
|
||||
# معماری — تسک ۱۳: ماژول لایک
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Like/
|
||||
├── Controller/
|
||||
│ └── LikeController.php
|
||||
├── Service/
|
||||
│ └── LikeService.php
|
||||
├── Repository/
|
||||
│ └── LikeRepository.php
|
||||
├── Entity/
|
||||
│ └── Like.php
|
||||
└── DTO/
|
||||
└── Request/
|
||||
└── CreateLikeRequest.php
|
||||
```
|
||||
|
||||
## Entity: Like
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'likes')]
|
||||
class Like
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $user;
|
||||
|
||||
// نوع موجودیت: blog, doctor
|
||||
#[ORM\Column(length: 30)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'boolean', default: true)]
|
||||
private bool $isLiked;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
# پایگاه داده — تسک ۱۳: ماژول لایک
|
||||
|
||||
## ساختار واقعی از Drupal (از config تأیید شده)
|
||||
|
||||
در Drupal، لایک به عنوان bundle=`like` در entity `clinic_pro_comment` ذخیره میشود:
|
||||
- `field_like` (boolean) → آیا لایک است یا آنلایک
|
||||
- `field_comment_id` (entity_reference → comment) → لایک مربوط به کدام کامنت
|
||||
|
||||
لایکها **فقط** روی نظرات (comment) هستند، نه blog یا doctor.
|
||||
|
||||
## جدول: likes
|
||||
_(entity_type=clinic_pro_comment, bundle=like)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | کاربری که لایک زده |
|
||||
| comment_id | INT FK → comments.id NOT NULL | field_comment_id | کامنت مورد نظر |
|
||||
| is_liked | TINYINT(1) DEFAULT 1 | field_like | 1=لایک، 0=آنلایک |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_likes_user_comment ON likes(user_id, comment_id);
|
||||
CREATE INDEX idx_likes_comment ON likes(comment_id);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- در Drupal، لایک فقط برای **comment** است (نه blog یا doctor)
|
||||
- `field_like` boolean است — کاربر میتواند لایک (1) یا آنلایک (0) ثبت کند
|
||||
- UNIQUE(user_id, comment_id) تضمین میکند هر کاربر فقط یک بار لایک/آنلایک بزند
|
||||
@@ -0,0 +1,21 @@
|
||||
# نکات پیادهسازی — تسک ۱۳: ماژول لایک
|
||||
|
||||
## Toggle Like
|
||||
PATCH endpoint باید is_liked را toggle کند:
|
||||
```php
|
||||
public function toggle(Like $like): void
|
||||
{
|
||||
$like->setIsLiked(!$like->isLiked());
|
||||
$this->em->flush();
|
||||
}
|
||||
```
|
||||
|
||||
## جلوگیری از لایک دوگانه
|
||||
unique index روی (user_id, entity_type, entity_id) جلوگیری میکند.
|
||||
اگر قبلاً لایک وجود داشت، PATCH برای toggle استفاده میشود.
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /like → احراز هویتشده
|
||||
PATCH /like/{uuid} → owner
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# تسک ۱۳: ماژول لایک
|
||||
|
||||
## توضیح
|
||||
سیستم لایک برای بلاگها یا دکترها.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/clinicpro/like` | ثبت لایک | بله |
|
||||
| PATCH | `/api/v1/clinicpro/like/{uuid}` | ویرایش/حذف لایک (toggle) | بله |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲
|
||||
|
||||
## زمان تخمینی
|
||||
۲ تا ۳ ساعت
|
||||
@@ -0,0 +1,50 @@
|
||||
# معماری — تسک ۱۴: ماژول منشی
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Secretary/
|
||||
├── Controller/
|
||||
│ └── SecretaryController.php
|
||||
├── Service/
|
||||
│ └── SecretaryService.php
|
||||
├── Repository/
|
||||
│ └── SecretaryRepository.php
|
||||
├── Entity/
|
||||
│ └── Secretary.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ ├── CreateSecretaryRequest.php
|
||||
│ │ └── UpdateSecretaryRequest.php
|
||||
│ └── Response/
|
||||
│ └── SecretaryResponse.php
|
||||
└── Voter/
|
||||
└── SecretaryVoter.php
|
||||
```
|
||||
|
||||
## Entity: Secretary
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'secretaries')]
|
||||
class Secretary
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $user; // حساب کاربری منشی
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor; // دکتر مربوطه
|
||||
|
||||
#[ORM\Column(length: 20, default: 'active')]
|
||||
private string $status;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $permissions; // ['manage_appointments', 'view_payments', ...]
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# پایگاه داده — تسک ۱۴: ماژول منشی
|
||||
|
||||
## جدول: doctor_secretaries
|
||||
_(entity_type=clinic_pro, bundle=doctor_secretary — از config تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | مالک رکورد |
|
||||
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor | دکتر (entity ref → clinic_pro/doctor) |
|
||||
| secretary_id | INT FK → users.id NOT NULL | field_secretary | منشی (entity ref → user) |
|
||||
| telephone | VARCHAR(50) NULL | field_telephone | تلفن تماس منشی |
|
||||
| permission | LONGTEXT NULL | field_permission | مجوزها (JSON یا متن) |
|
||||
| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال (نه status VARCHAR!) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_secretary_doctor_user ON doctor_secretaries(doctor_id, secretary_id);
|
||||
CREATE INDEX idx_secretary_doctor ON doctor_secretaries(doctor_id);
|
||||
CREATE INDEX idx_secretary_user ON doctor_secretaries(secretary_id);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- فیلد وضعیت: `active` (TINYINT boolean) — نه `status` با مقادیر string
|
||||
- `field_permission` نوع string_long است (LONGTEXT)، میتواند JSON یا متن ساده باشد
|
||||
- `doctor_id` → FK به doctors.id (نه clinic_pro.id) — در Symfony به entity doctor اشاره میکند
|
||||
- `secretary_id` → FK به users.id — کاربری که نقش منشی دارد
|
||||
- UNIQUE(doctor_id, secretary_id): یک منشی نمیتواند دو بار برای یک دکتر ثبت شود
|
||||
@@ -0,0 +1,25 @@
|
||||
# نکات پیادهسازی — تسک ۱۴: ماژول منشی
|
||||
|
||||
## نقش کاربری
|
||||
هنگام ایجاد secretary، نقش `ROLE_SECRETARY` به user مرتبط اضافه میشود.
|
||||
هنگام حذف، نقش را remove کن (اگر منشی دکتر دیگری نیست).
|
||||
|
||||
## مجوزهای منشی
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"manage_appointments", // مدیریت نوبتها
|
||||
"view_payments", // مشاهده پرداختها
|
||||
"manage_schedule" // مدیریت برنامه
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## مجوزها در سیستم
|
||||
```
|
||||
POST → دکتر (برای خودش) یا ROLE_ADMIN
|
||||
PATCH → دکتر مرتبط یا ROLE_ADMIN
|
||||
DELETE → دکتر مرتبط یا ROLE_ADMIN
|
||||
GET → دکتر مرتبط، خود منشی، یا ROLE_ADMIN
|
||||
GET list → دکتر مرتبط یا ROLE_ADMIN
|
||||
```
|
||||
@@ -0,0 +1,313 @@
|
||||
# تسک ۱۴: ماژول منشی
|
||||
|
||||
## توضیح
|
||||
مدیریت منشیهای دکترها که میتوانند نوبتها و پرداختها را مدیریت کنند.
|
||||
هر دکتر بسته به پلن اشتراک میتواند ۱ یا ۳ منشی فعال داشته باشد.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/secretary` | ایجاد منشی | بله (Doctor/Admin) |
|
||||
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش منشی | بله (Doctor/Admin) |
|
||||
| GET | `/api/v1/secretary/{uuid}` | دریافت اطلاعات منشی | بله |
|
||||
| DELETE | `/api/v1/secretary/{uuid}` | حذف منشی | بله (Doctor/Admin) |
|
||||
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیهای دکتر | بله |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۰۵ (Doctor)
|
||||
|
||||
## زمان تخمینی
|
||||
۵ تا ۶ ساعت
|
||||
|
||||
---
|
||||
|
||||
## سیستم مجوزها — Resource-Based Permissions (مقیاسپذیر)
|
||||
|
||||
فیلد `permissions` در جدول `doctor_secretaries` یک JSON ساختاریافته با نسخهبندی است.
|
||||
طراحی به گونهای است که در آینده بتوان منابع (`resources`) و عملیات (`actions`) جدید اضافه کرد بدون تغییر در ساختار جدول.
|
||||
|
||||
### ساختار JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"resources": {
|
||||
"appointments": {
|
||||
"view": true,
|
||||
"create": true,
|
||||
"cancel": false,
|
||||
"update_status": true
|
||||
},
|
||||
"addresses": {
|
||||
"view": true,
|
||||
"create": true,
|
||||
"update": true,
|
||||
"delete": false
|
||||
},
|
||||
"clinic_info": {
|
||||
"view": true,
|
||||
"update": false
|
||||
},
|
||||
"insurances": {
|
||||
"view": true,
|
||||
"create": true,
|
||||
"update": true,
|
||||
"delete": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### منابع و عملیات فعلی
|
||||
|
||||
| Resource | Actions | توضیح |
|
||||
|----------|---------|-------|
|
||||
| `appointments` | `view`, `create`, `cancel`, `update_status` | نوبتها |
|
||||
| `addresses` | `view`, `create`, `update`, `delete` | آدرسهای مطب/کلینیک |
|
||||
| `clinic_info` | `view`, `update` | اطلاعات مطب یا کلینیک |
|
||||
| `insurances` | `view`, `create`, `update`, `delete` | بیمهها |
|
||||
|
||||
### مقیاسپذیری — اضافه کردن Resource جدید در آینده
|
||||
|
||||
برای اضافه کردن Resource جدید (مثلاً `patients` یا `reports`) فقط کافی است:
|
||||
1. کلید جدید به JSON اضافه شود — بدون migration جدید
|
||||
2. کد Permission Checker به صورت خودکار آن را پشتیبانی میکند
|
||||
3. منشیهای موجود که کلید جدید را ندارند، به صورت پیشفرض `false` دارند
|
||||
|
||||
### پیادهسازی PHP — SecretaryPermissionChecker
|
||||
|
||||
```php
|
||||
// src/Secretary/Security/SecretaryPermissionChecker.php
|
||||
class SecretaryPermissionChecker
|
||||
{
|
||||
/**
|
||||
* بررسی مجوز منشی برای یک عملیات روی یک منبع
|
||||
* مثال: $checker->can($secretary, 'appointments', 'create')
|
||||
*/
|
||||
public function can(Secretary $secretary, string $resource, string $action): bool
|
||||
{
|
||||
if (!$secretary->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = $secretary->getPermissions();
|
||||
return (bool) ($permissions['resources'][$resource][$action] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* بررسی دسترسی کامل به یک منبع (همه actions باید true باشند)
|
||||
*/
|
||||
public function canAll(Secretary $secretary, string $resource, array $actions): bool
|
||||
{
|
||||
return array_reduce(
|
||||
$actions,
|
||||
fn($carry, $action) => $carry && $this->can($secretary, $resource, $action),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**مثال استفاده در Controller:**
|
||||
```php
|
||||
// در AppointmentController
|
||||
if (!$this->permissionChecker->can($secretary, 'appointments', 'create')) {
|
||||
throw new AccessDeniedHttpException('منشی مجاز به ثبت نوبت نیست');
|
||||
}
|
||||
|
||||
// در InsuranceController
|
||||
if (!$this->permissionChecker->can($secretary, 'insurances', 'delete')) {
|
||||
throw new AccessDeniedHttpException('منشی مجاز به حذف بیمه نیست');
|
||||
}
|
||||
```
|
||||
|
||||
### پیشفرض هنگام ایجاد منشی
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/secretary
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"mobile_number": "09120671756",
|
||||
"doctor_uuid": "61be915b-...",
|
||||
"permissions": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"user": { "uuid": "...", "realname": "فاطمه رضایی", "mobile": "09120671756" },
|
||||
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||
"active": true,
|
||||
"permissions": { ... },
|
||||
"created_at": 1748000000
|
||||
}
|
||||
}
|
||||
|
||||
// Response 422 — حد مجاز منشی
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SECRETARY_001", "message": "پلن فعلی اجازه منشی بیشتر را نمیدهد" }]
|
||||
}
|
||||
```
|
||||
|
||||
**قانون بررسی پلن (سمت سرور):**
|
||||
```
|
||||
پلن بیسیک → max 1 منشی فعال
|
||||
پلن پیشرفته → max 3 منشی فعال
|
||||
|
||||
هنگام POST /secretary:
|
||||
activeCount = COUNT(*) WHERE doctor_id=X AND active=true
|
||||
if activeCount >= maxAllowed → 422
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PATCH /api/v1/secretary/{uuid}
|
||||
|
||||
```json
|
||||
// Request (فقط resources موردنظر — deep merge با پیشفرضها)
|
||||
{
|
||||
"active": false,
|
||||
"permissions": {
|
||||
"resources": {
|
||||
"insurances": {
|
||||
"create": true,
|
||||
"update": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// نکته: فقط resources/actions ارسالشده تغییر میکنند — بقیه دستنخورده میمانند
|
||||
```
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/secretary/{uuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"user": {
|
||||
"uuid": "...",
|
||||
"realname": "فاطمه رضایی",
|
||||
"mobile": "09120671756",
|
||||
"picture": null
|
||||
},
|
||||
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||
"active": true,
|
||||
"permissions": {
|
||||
"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 }
|
||||
}
|
||||
},
|
||||
"created_at": 1748000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/secretaries/{doctorUuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"user": { "uuid": "...", "realname": "فاطمه رضایی", "mobile": "09120671756" },
|
||||
"active": true,
|
||||
"permissions": { ... },
|
||||
"created_at": 1748000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **کاربر منشی:** هنگام ایجاد منشی با mobile_number، ابتدا بررسی میشود آیا کاربر با این شماره وجود دارد — اگر نه، کاربر جدید ایجاد میشود
|
||||
- **ROLE:** کاربر منشی باید role `doctor_s_secretary` داشته باشد
|
||||
- **لاگین منشی:** منشی میتواند با username/password لاگین کند (تسک ۰۲)
|
||||
- **بررسی پلن:** کاملاً سمت سرور انجام میشود، قابل دور زدن نیست
|
||||
- **نوبت آفلاین:** نوبتی که منشی ثبت میکند (`appointments.create`) کمیسیون نماینده ندارد
|
||||
- **PATCH permissions:** فقط resources/actions ارسالشده تغییر میکنند (deep merge) — بقیه دستنخورده
|
||||
- **Resource ناشناخته:** اگر resource جدیدی در JSON باشد که سرور نمیشناسد، نادیده گرفته میشود (forward compat)
|
||||
- **پیشفرض `false`:** اگر resource یا action در JSON وجود نداشته باشد → `false` (deny by default)
|
||||
@@ -0,0 +1,67 @@
|
||||
# معماری — تسک ۱۵: ماژول پرداخت
|
||||
|
||||
## ساختار فایلها
|
||||
```
|
||||
src/Module/Payment/
|
||||
├── Controller/
|
||||
│ ├── PaymentController.php ← ایجاد و دریافت پرداخت
|
||||
│ └── PaymentCallbackController.php ← callback درگاه پرداخت
|
||||
├── Service/
|
||||
│ ├── PaymentService.php
|
||||
│ └── Gateway/
|
||||
│ ├── PaymentGatewayInterface.php
|
||||
│ ├── ZarinpalGateway.php ← درگاه زرینپال
|
||||
│ └── NullGateway.php ← برای محیط dev
|
||||
├── Repository/
|
||||
│ └── PaymentRepository.php
|
||||
├── Entity/
|
||||
│ └── Payment.php
|
||||
├── DTO/
|
||||
│ ├── Request/
|
||||
│ │ └── CreatePaymentRequest.php
|
||||
│ └── Response/
|
||||
│ └── PaymentResponse.php
|
||||
└── Voter/
|
||||
└── PaymentVoter.php
|
||||
```
|
||||
|
||||
## Entity: Payment
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'payments')]
|
||||
class Payment
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||
private Uuid $uuid;
|
||||
|
||||
#[ORM\OneToOne(targetEntity: Appointment::class)]
|
||||
private Appointment $appointment;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private User $user;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $amount; // ریال
|
||||
|
||||
// pending, paid, failed, refunded
|
||||
#[ORM\Column(length: 20, default: 'pending')]
|
||||
private string $status;
|
||||
|
||||
#[ORM\Column(length: 30, nullable: true)]
|
||||
private ?string $paymentMethod; // online, cash, insurance
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
private ?string $gatewayToken; // توکن درگاه
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $referenceCode; // کد پیگیری
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $paidAt;
|
||||
|
||||
// TimestampableTrait
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# پایگاه داده — تسک ۱۵: ماژول پرداخت
|
||||
|
||||
## جدول: payments
|
||||
_(entity_type=payment, bundle=appointment — از DB backup تأیید شده)_
|
||||
|
||||
| ستون | نوع | نام Drupal | توضیح |
|
||||
|------|-----|-----------|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||
| user_id | INT FK → users.id NOT NULL | uid | پرداختکننده |
|
||||
| appointment_id | INT FK → appointments.id UNIQUE NULL | field_reference_id | entity ref → appointment |
|
||||
| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro |
|
||||
| amount | INT NOT NULL | field_amount | مبلغ به **ریال** (نه تومان) — تایپ INT |
|
||||
| status | VARCHAR(20) DEFAULT 'pending' | field_status | وضعیت |
|
||||
| payment_method | VARCHAR(20) NULL | field_payment_method | روش پرداخت |
|
||||
| ref_id | VARCHAR(100) NULL | field_ref_id | SaleReferenceId بانک |
|
||||
| frontend_address | VARCHAR(150) NULL | field_frontend_address | URL فرانت برای redirect |
|
||||
| payment_time | INT NULL | field_payment_time | زمان پرداخت (Unix timestamp) |
|
||||
| card_info | LONGTEXT NULL | field_card_info | اطلاعات کارت (JSON/text) |
|
||||
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||
|
||||
## وضعیتهای پرداخت (field_status) — از config
|
||||
```
|
||||
pending → ایجاد شده، منتظر پرداخت
|
||||
received → پرداخت موفق تأیید شده ⚠️ نه 'paid'!
|
||||
refund → مبلغ برگشت خورده
|
||||
canceled → لغو شده (نه 'failed')
|
||||
```
|
||||
|
||||
## روشهای پرداخت (field_payment_method) — از config
|
||||
```
|
||||
mellat → بانک ملت (SOAP)
|
||||
sep → بانک سامان (SEP)
|
||||
```
|
||||
|
||||
## نمونه داده واقعی از DB backup
|
||||
```
|
||||
id=2, uid=33, amount=100000 (ریال), status=pending, method=mellat
|
||||
frontend_address='http://yasuj-nobat.localhost:3000/'
|
||||
```
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_payments_appointment ON payments(appointment_id);
|
||||
CREATE INDEX idx_payments_user ON payments(user_id);
|
||||
CREATE INDEX idx_payments_status ON payments(status);
|
||||
CREATE INDEX idx_payments_representation ON payments(representation_id);
|
||||
CREATE INDEX idx_payments_ref_id ON payments(ref_id);
|
||||
```
|
||||
|
||||
## وابستگی وضعیت appointment
|
||||
```
|
||||
appointments.status = 'waiting_for_payment' → پیشنیاز ایجاد payment
|
||||
بعد از پرداخت موفق:
|
||||
payments.status = 'received'
|
||||
appointments.status = 'reserved'
|
||||
payment_time = Unix timestamp الان
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- `amount` نوع **INT** است (نه DECIMAL) — از DB backup تأیید شد (مثال: 100000)
|
||||
- مبلغ در **ریال** ذخیره میشود
|
||||
- `field_reference_id` → entity reference به appointment (نه foreign key مستقیم در جدول payment)
|
||||
- `frontend_address` برای redirect بعد از پرداخت به سایت نماینده است
|
||||
- `card_info` برای ذخیره اطلاعات کارت بانکی (مثلاً شماره کارت ماسکشده)
|
||||
|
||||
---
|
||||
|
||||
## جدول: subscription_payments (پرداخت اشتراک)
|
||||
_(از بخش ۲.۱۰.۲ مستند — نوع پرداخت مجزا از پرداخت نوبت)_
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| user_id | INT FK → users.id NOT NULL | کاربر سابسکرایبکننده |
|
||||
| reference_type | VARCHAR(10) NOT NULL | `doctor` یا `clinic` |
|
||||
| reference_id | INT NOT NULL | FK به doctors.id یا clinics.id |
|
||||
| representation_id | INT FK → representations.id NULL | نماینده (در صورت وجود) |
|
||||
| amount | INT NOT NULL | مبلغ اشتراک (ریال) |
|
||||
| payment_method | VARCHAR(20) NULL | روش پرداخت (mellat, sep, ...) |
|
||||
| payment_time | INT NULL | زمان پرداخت (Unix timestamp) |
|
||||
| start_date | INT NOT NULL | تاریخ شروع اشتراک (Unix timestamp) |
|
||||
| expiration_date | INT NOT NULL | تاریخ انقضای اشتراک (Unix timestamp) |
|
||||
| ref_id | VARCHAR(100) NULL | شماره مرجع درگاه بانکی |
|
||||
| card_info | LONGTEXT NULL | اطلاعات کارت بانکی (JSON) |
|
||||
| frontend_address | VARCHAR(150) NULL | آدرس بازگشت پس از پرداخت |
|
||||
| status | VARCHAR(20) DEFAULT 'pending' | pending \| received \| refund \| canceled |
|
||||
| created_at | INT NOT NULL | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | Unix timestamp |
|
||||
|
||||
## ایندکسهای subscription_payments
|
||||
```sql
|
||||
CREATE INDEX idx_sub_payments_user ON subscription_payments(user_id);
|
||||
CREATE INDEX idx_sub_payments_ref ON subscription_payments(reference_type, reference_id);
|
||||
CREATE INDEX idx_sub_payments_status ON subscription_payments(status);
|
||||
CREATE INDEX idx_sub_payments_expiry ON subscription_payments(expiration_date);
|
||||
```
|
||||
|
||||
## تفاوت payments و subscription_payments
|
||||
|
||||
| ویژگی | payments | subscription_payments |
|
||||
|-------|----------|----------------------|
|
||||
| مرجع | `appointment_id` | `reference_id` → doctor/clinic |
|
||||
| فیلدهای اضافه | — | `start_date`, `expiration_date` |
|
||||
| هدف | پرداخت نوبت | خرید اشتراک پلن |
|
||||
@@ -0,0 +1,99 @@
|
||||
# نکات پیادهسازی — تسک ۱۵: ماژول پرداخت
|
||||
|
||||
## درگاههای واقعی پروژه (از کد Drupal)
|
||||
|
||||
### ۱. بانک ملت (Mellat) — پروتکل SOAP
|
||||
```php
|
||||
// وبسرویس SOAP با متدهای:
|
||||
// bpPayRequest → شروع تراکنش
|
||||
// bpVerifyRequest → تأیید پرداخت
|
||||
// bpInquiryRequest → استعلام وضعیت
|
||||
// bpSettleRequest → تسویه
|
||||
// bpReversalRequest → برگشت تراکنش
|
||||
|
||||
// پارامترهای پیکربندی:
|
||||
terminal_id, username, password
|
||||
wsdl_endpoint, gate_url
|
||||
test_mode (boolean)
|
||||
callback_url, callback_url_test
|
||||
```
|
||||
|
||||
### ۲. SEP (سامان) — درگاه دوم
|
||||
در `sep_payment/src/Plugin/MyPayment/SepPayment.php` پیادهسازی شده.
|
||||
|
||||
### پیادهسازی در Symfony
|
||||
```php
|
||||
interface PaymentGatewayInterface {
|
||||
public function pay(array $data): array; // { success, ref_id, gateway_url }
|
||||
public function verify(array $callbackData, int $orderId): array;
|
||||
public function refund(array $data): array;
|
||||
}
|
||||
```
|
||||
|
||||
پیکربندی در `.env`:
|
||||
```
|
||||
PAYMENT_GATEWAY=mellat # mellat | sep
|
||||
MELLAT_TERMINAL_ID=...
|
||||
MELLAT_USERNAME=...
|
||||
MELLAT_PASSWORD=...
|
||||
MELLAT_TEST_MODE=true
|
||||
```
|
||||
|
||||
## وضعیت پرداخت (از کد واقعی)
|
||||
```
|
||||
pending → بعد از ایجاد پرداخت
|
||||
received → بعد از تأیید موفق (نه "paid"!)
|
||||
failed → پرداخت ناموفق
|
||||
```
|
||||
⚠️ در Drupal status موفق `received` است نه `paid`.
|
||||
|
||||
## شرط ایجاد پرداخت
|
||||
**appointment باید status=`waiting_for_payment` داشته باشد.**
|
||||
اگر status متفاوت باشد → 400 error.
|
||||
|
||||
## قیمت از Config (نه Request)
|
||||
```php
|
||||
// مبلغ از پیکربندی خوانده میشود، نه از request body!
|
||||
$amount = $config->get('payment.settings')['price'];
|
||||
```
|
||||
→ در `.env` یا config:
|
||||
```
|
||||
APPOINTMENT_PRICE=500000
|
||||
```
|
||||
|
||||
## frontend_address
|
||||
پرداخت دارای `frontend_address` است — URL فرانت برای redirect بعد از پرداخت.
|
||||
این به representation مرتبط است (سیستم multi-tenant).
|
||||
|
||||
## Callback Mellat
|
||||
```
|
||||
POST /payment/callback/mellat
|
||||
RefId=...
|
||||
ResCode=0
|
||||
SaleOrderId=...
|
||||
SaleReferenceId=...
|
||||
|
||||
جریان:
|
||||
1. ResCode === '0' باشد
|
||||
2. bpVerifyRequest → اگر موفق نبود → bpInquiryRequest → اگر موفق نبود → bpReversalRequest
|
||||
3. bpSettleRequest (resCode='0' یا '45' = قبلاً تسویه شده)
|
||||
4. appointment.status = confirmed
|
||||
5. payment.status = received
|
||||
6. payment.ref_id = SaleReferenceId
|
||||
```
|
||||
|
||||
## Idempotency
|
||||
اگر callback دوبار بیاید، دوبار process نشود:
|
||||
```php
|
||||
if ($payment->getStatus() === 'received') {
|
||||
return; // قبلاً پردازش شده
|
||||
}
|
||||
```
|
||||
|
||||
## مجوزها
|
||||
```
|
||||
POST /payment → احراز هویتشده
|
||||
GET /payment/{uuid} → owner یا ROLE_ADMIN یا دکتر مرتبط
|
||||
GET /my-payments → owner
|
||||
GET/POST callback → عمومی (درگاه پرداخت)
|
||||
```
|
||||
@@ -0,0 +1,285 @@
|
||||
# تسک ۱۵: ماژول پرداخت
|
||||
|
||||
## توضیح
|
||||
مدیریت پرداخت نوبتها از طریق درگاههای Mellat و SEP،
|
||||
callback پرداخت، refund و مشاهده تاریخچه.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| POST | `/api/v1/payment` | شروع فرآیند پرداخت | بله |
|
||||
| GET | `/api/v1/payment/{uuid}` | دریافت اطلاعات پرداخت | بله |
|
||||
| GET | `/api/v1/payment/my-payments/{userId}` | تاریخچه پرداختهای من | بله |
|
||||
| POST | `/api/v1/payment/callback/mellat` | Callback از درگاه ملت | خیر (IP whitelist) |
|
||||
| POST | `/api/v1/payment/callback/sep` | Callback از درگاه سامان | خیر (IP whitelist) |
|
||||
| POST | `/api/v1/subscription-payment` | شروع پرداخت اشتراک | بله |
|
||||
| GET | `/api/v1/subscription-payment/{uuid}` | اطلاعات پرداخت اشتراک | بله |
|
||||
| POST | `/api/v1/subscription-payment/callback/mellat` | Callback اشتراک ملت | خیر |
|
||||
| POST | `/api/v1/subscription-payment/callback/sep` | Callback اشتراک سامان | خیر |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۱۰ (Appointment)
|
||||
|
||||
## زمان تخمینی
|
||||
۱۰ تا ۱۲ ساعت
|
||||
|
||||
---
|
||||
|
||||
## فلوی کامل پرداخت نوبت
|
||||
|
||||
```
|
||||
۱. POST /api/v1/payment
|
||||
↓
|
||||
۲. بررسی: appointment.status == 'waiting_for_payment' ؟
|
||||
↓ (بله)
|
||||
۳. ایجاد رکورد payment با status=pending
|
||||
↓
|
||||
۴. فراخوانی PaymentGatewayInterface::initiate(amount, callback_url)
|
||||
↓
|
||||
┌──────────────────┬──────────────────┐
|
||||
Mellat (SOAP) SEP (REST)
|
||||
→ bpPayRequest → MerchantSendTransaction
|
||||
→ دریافت RefId → دریافت token
|
||||
↓
|
||||
۵. بازگشت payment_url به کلاینت
|
||||
↓
|
||||
۶. Redirect کاربر به درگاه بانک
|
||||
↓
|
||||
۷. [Callback از بانک]
|
||||
↓
|
||||
۸. POST /api/v1/payment/callback/{gateway}
|
||||
↓
|
||||
۹. تأیید تراکنش با درگاه (VerifyRequest)
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
پرداخت موفق پرداخت ناموفق
|
||||
↓ ↓
|
||||
payments.status=received payments.status=canceled
|
||||
appointments.status=reserved appointments.status=waiting_for_payment
|
||||
واریز کمیسیون نماینده (کاربر میتواند مجدداً تلاش کند)
|
||||
↓
|
||||
Redirect به frontend_address
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Strategy Pattern برای درگاهها
|
||||
|
||||
```php
|
||||
interface PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(int $amount, string $callbackUrl, string $description): GatewayInitResult;
|
||||
public function verify(string $refId, int $amount): GatewayVerifyResult;
|
||||
public function getName(): string; // 'mellat' | 'sep'
|
||||
}
|
||||
|
||||
class MellatGateway implements PaymentGatewayInterface { ... }
|
||||
class SepGateway implements PaymentGatewayInterface { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/payment
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"appointment_uuid": "7b759d2a-...",
|
||||
"payment_method": "mellat",
|
||||
"frontend_address": "https://yasuj-nobat.localhost:3000/"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"payment_url": "https://bpm.shaparak.ir/pgwchannel/startpay.mellat?RefId=xxx",
|
||||
"amount": 500000,
|
||||
"status": "pending",
|
||||
"expires_at": 1748001800
|
||||
}
|
||||
}
|
||||
|
||||
// Response 400 — نوبت در وضعیت نامناسب
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_PAYMENT_003", "message": "وضعیت نوبت برای پرداخت مناسب نیست" }]
|
||||
}
|
||||
|
||||
// Response 503 — درگاه در دسترس نیست
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_PAYMENT_001", "message": "درگاه پرداخت در حال حاضر در دسترس نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/payment/callback/mellat
|
||||
|
||||
```
|
||||
// form-data از بانک
|
||||
ResCode=0
|
||||
SaleOrderId=...
|
||||
SaleReferenceId=12345678
|
||||
```
|
||||
|
||||
**منطق:**
|
||||
```
|
||||
1. پیدا کردن payment با ref_id مربوطه
|
||||
2. فراخوانی MellatGateway::verify(SaleReferenceId, amount)
|
||||
3. اگر موفق:
|
||||
- payments.status = 'received'
|
||||
- payments.ref_id = SaleReferenceId
|
||||
- payments.payment_time = now()
|
||||
- appointments.status = 'reserved'
|
||||
- محاسبه و واریز کمیسیون نماینده (async)
|
||||
4. Redirect به frontend_address + ?status=success
|
||||
5. اگر ناموفق:
|
||||
- payments.status = 'canceled'
|
||||
- Redirect به frontend_address + ?status=failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/payment/{uuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"appointment": {
|
||||
"uuid": "...",
|
||||
"date": "2024-03-20",
|
||||
"time": "09:00",
|
||||
"doctor": { "name": "دکتر احمدی" }
|
||||
},
|
||||
"amount": 500000,
|
||||
"status": "received",
|
||||
"payment_method": "mellat",
|
||||
"ref_id": "12345678",
|
||||
"payment_time": 1748000000,
|
||||
"created_at": 1748000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## فلوی Refund (لغو نوبت بعد از پرداخت)
|
||||
|
||||
```
|
||||
PATCH /api/v1/appointment/{uuid}/cancel
|
||||
↓
|
||||
appointment.status = 'cancelled_by_patient'
|
||||
↓
|
||||
payment.status = 'refund'
|
||||
↓
|
||||
ثبت در سیستم — refund واقعی دستی توسط ادمین انجام میشود
|
||||
↓
|
||||
log در سیستم برای پیگیری ادمین
|
||||
```
|
||||
|
||||
> **نکته:** Refund خودکار از درگاه در این پروژه پیادهسازی نمیشود — ادمین به صورت دستی مبلغ را برمیگرداند.
|
||||
|
||||
---
|
||||
|
||||
## Subscription Payment — POST /api/v1/subscription-payment
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"reference_type": "doctor",
|
||||
"reference_id": 29,
|
||||
"plan": "advanced",
|
||||
"payment_method": "mellat",
|
||||
"frontend_address": "https://yasuj-nobat.localhost:3000/"
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"payment_url": "https://bpm.shaparak.ir/...",
|
||||
"amount": 5000000,
|
||||
"plan": "advanced",
|
||||
"status": "pending"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**بعد از تأیید پرداخت اشتراک:**
|
||||
```
|
||||
subscription_payments.status = 'received'
|
||||
subscription_payments.start_date = now()
|
||||
subscription_payments.expiration_date = now() + 30 روز (یا 365 روز)
|
||||
واریز کمیسیون به کیف پول نماینده (اگر از طریق نماینده)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **مبلغ در ریال ذخیره میشود** (نه تومان) — مثال: ۵۰,۰۰۰ تومان = ۵۰۰,۰۰۰ ریال
|
||||
- **وضعیت 'received'** — نه 'paid' (مستقیم از Drupal)
|
||||
- **Circuit Breaker:** اگر درگاه ۳ بار پشت سر هم fail داشت → به مدت ۵ دقیقه blocked شود
|
||||
- **Idempotency:** Callback ممکن است چند بار فراخوانی شود — بررسی کنید payment قبلاً verified نشده باشد
|
||||
- **IP Whitelist:** Callback endpoint ها باید فقط از IP های بانک قابل دسترس باشند
|
||||
|
||||
---
|
||||
|
||||
## ⚠ امنیت: جلوگیری از Open Redirect
|
||||
|
||||
فیلد `frontend_address` در request میتواند توسط مهاجم دستکاری شود تا Callback به یک سایت مخرب redirect کند.
|
||||
|
||||
**راهحل — Whitelist دامنههای مجاز:**
|
||||
|
||||
```php
|
||||
// config/packages/payment.yaml (یا .env)
|
||||
ALLOWED_FRONTEND_HOSTS=yasuj-nobat.localhost,clinicpro.ir,app.clinicpro.ir
|
||||
|
||||
// در PaymentService قبل از ذخیره frontend_address:
|
||||
private function validateFrontendAddress(string $url): void
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
$host = $parsed['host'] ?? '';
|
||||
$allowed = explode(',', $this->params->get('allowed_frontend_hosts'));
|
||||
|
||||
if (!in_array($host, $allowed, true)) {
|
||||
throw new \InvalidArgumentException('آدرس بازگشت مجاز نیست');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**یا روش سادهتر:** `frontend_address` را از JWT کاربر یا از `representations.domain_name` بخوان — نه از request body.
|
||||
|
||||
---
|
||||
|
||||
## ⚠ امنیت: IP Whitelist برای Callback
|
||||
|
||||
```php
|
||||
// src/Payment/EventSubscriber/PaymentCallbackGuard.php
|
||||
class PaymentCallbackGuard implements EventSubscriberInterface
|
||||
{
|
||||
private const MELLAT_IPS = ['185.143.233.0/24', '79.175.148.0/24'];
|
||||
private const SEP_IPS = ['195.146.48.0/24'];
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (!str_contains($path, '/payment/callback/')) return;
|
||||
|
||||
$clientIp = $event->getRequest()->getClientIp();
|
||||
$gateway = str_contains($path, 'mellat') ? 'mellat' : 'sep';
|
||||
$allowed = $gateway === 'mellat' ? self::MELLAT_IPS : self::SEP_IPS;
|
||||
|
||||
if (!$this->ipInRanges($clientIp, $allowed)) {
|
||||
throw new AccessDeniedHttpException('IP not allowed for payment callback');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 → درصد کمیسیون
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# پایگاه داده — تسک ۱۷: ماژول پیامک
|
||||
|
||||
## جدول: sms_accounts (حساب پیامک)
|
||||
_(از Manual بخش ۴.۱ — entity جدید در Symfony)_
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| user_id | INT FK → users.id NOT NULL | کاربر مالک |
|
||||
| owner_type | VARCHAR(10) NOT NULL | `doctor` یا `clinic` |
|
||||
| owner_id | INT NOT NULL | FK به doctors.id یا clinics.id |
|
||||
| balance | INT DEFAULT 0 | تعداد پیامک باقیمانده |
|
||||
| created_at | INT NOT NULL | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | Unix timestamp |
|
||||
|
||||
## جدول: sms_queue (صف پیامک)
|
||||
_(از Manual بخش ۴.۲)_
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT AUTO_INCREMENT PK | |
|
||||
| account_id | INT FK → sms_accounts.id CASCADE | حساب پیامک مالک |
|
||||
| recipient_mobile | VARCHAR(20) NOT NULL | شماره موبایل گیرنده |
|
||||
| message | TEXT NOT NULL | متن پیامک |
|
||||
| scheduled_at | INT NOT NULL | زمان برنامهریزیشده (Unix timestamp) — معمولاً یک روز قبل از نوبت |
|
||||
| sent_at | INT NULL | زمان واقعی ارسال |
|
||||
| status | VARCHAR(20) DEFAULT 'queued' | `queued`, `sent`, `failed`, `cancelled` |
|
||||
| created_at | INT NOT NULL | |
|
||||
| updated_at | INT NOT NULL | |
|
||||
|
||||
## ایندکسها
|
||||
```sql
|
||||
CREATE UNIQUE INDEX idx_sms_accounts_owner ON sms_accounts(owner_type, owner_id);
|
||||
CREATE INDEX idx_sms_queue_account ON sms_queue(account_id);
|
||||
CREATE INDEX idx_sms_queue_status ON sms_queue(status, scheduled_at);
|
||||
CREATE INDEX idx_sms_queue_scheduled ON sms_queue(scheduled_at);
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
- هر دکتر/کلینیک فقط یک حساب پیامک دارد (UNIQUE owner_type, owner_id)
|
||||
- `scheduled_at` معمولاً یک روز قبل از `appointment.start_time` تنظیم میشود
|
||||
- Tauri (نرمافزار لوکال) پیامکها را در صف میگذارد، بکاند آنها را ارسال میکند
|
||||
- کسر موجودی باید atomic باشد (transaction)
|
||||
@@ -0,0 +1,70 @@
|
||||
# نکات پیادهسازی — تسک ۱۷: ماژول پیامک
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /api/v1/sms/balance
|
||||
```json
|
||||
// Response 200:
|
||||
{
|
||||
"owner_id": 29,
|
||||
"owner_type": "doctor",
|
||||
"balance": 850
|
||||
}
|
||||
|
||||
// Error 404: حساب پیامک وجود ندارد
|
||||
// Error 401: احراز هویت لازم است
|
||||
```
|
||||
|
||||
### POST /api/v1/sms/queue
|
||||
```json
|
||||
// Request:
|
||||
{
|
||||
"recipient_mobile": "09121234567",
|
||||
"message": "یادآوری: نوبت شما فردا ساعت ۱۰:۰۰ است",
|
||||
"scheduled_at": "2025-06-15T09:00:00+03:30"
|
||||
}
|
||||
|
||||
// Response 201:
|
||||
{
|
||||
"id": 1,
|
||||
"status": "queued",
|
||||
"scheduled_at": 1749970200,
|
||||
"remaining_balance": 849
|
||||
}
|
||||
|
||||
// Error 402: موجودی ناکافی
|
||||
// Error 400: شماره موبایل یا پیام نامعتبر
|
||||
```
|
||||
|
||||
## منطق کسر موجودی (atomic)
|
||||
```php
|
||||
// در SmsService.php:
|
||||
$this->entityManager->beginTransaction();
|
||||
$account = $this->smsAccountRepo->findByOwner($ownerType, $ownerId);
|
||||
if ($account->getBalance() <= 0) {
|
||||
throw new InsufficientBalanceException();
|
||||
}
|
||||
$account->decrementBalance();
|
||||
// add to queue
|
||||
$this->entityManager->commit();
|
||||
```
|
||||
|
||||
## تشخیص owner از JWT token
|
||||
```php
|
||||
// کاربر لاگینشده → چک کن دکتر است یا کلینیک
|
||||
$user = $this->getUser();
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
$ownerType = 'doctor'; $ownerId = $doctor->getId();
|
||||
} elseif ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
$ownerType = 'clinic'; $ownerId = $clinic->getId();
|
||||
}
|
||||
```
|
||||
|
||||
## نکته: ارسال واقعی پیامک
|
||||
پیامکها توسط یک Job/Command ارسال میشوند (نه در همان request):
|
||||
```
|
||||
php bin/console sms:send-queued
|
||||
```
|
||||
این command پیامکهایی با `status=queued` و `scheduled_at <= now` را ارسال میکند.
|
||||
@@ -0,0 +1,471 @@
|
||||
# تسک ۱۷: ماژول پیامک (SMS)
|
||||
|
||||
## توضیح
|
||||
سیستم پیامک یادآوری نوبت برای بیماران.
|
||||
هر دکتر/کلینیک یک حساب پیامک مستقل دارد که با خرید پیامک شارژ میشود.
|
||||
ارسال پیامک **async** از طریق Symfony Messenger انجام میشود.
|
||||
|
||||
دکتر یا کلینیک میتواند **تمپلیت پیامک سفارشی** برای هر دستهبندی بسازد.
|
||||
ادمین باید تمپلیت را تأیید کند — بعد از تأیید، در ارسال پیامک از آن استفاده میشود.
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/api/v1/sms/balance` | موجودی حساب پیامک | بله |
|
||||
| POST | `/api/v1/sms/queue` | افزودن پیامک به صف | بله |
|
||||
| GET | `/api/v1/sms/sample-templates` | مشاهده نمونه تمپلیتهای ادمین | بله (Doctor/Clinic) |
|
||||
| GET | `/api/v1/sms/templates` | لیست تمپلیتهای خودم | بله (Doctor/Clinic/Admin) |
|
||||
| POST | `/api/v1/sms/templates` | ساختن تمپلیت جدید | بله (Doctor/Clinic) |
|
||||
| GET | `/api/v1/sms/templates/{uuid}` | جزئیات تمپلیت | بله |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}` | ویرایش تمپلیت (قبل از ارسال به ادمین) | بله (Owner) |
|
||||
| DELETE | `/api/v1/sms/templates/{uuid}` | حذف تمپلیت | بله (Owner/Admin) |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}/submit` | ارسال به ادمین برای تأیید | بله (Owner) |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}/approve` | تأیید تمپلیت | بله (Admin) |
|
||||
| PATCH | `/api/v1/sms/templates/{uuid}/reject` | رد تمپلیت با دلیل | بله (Admin) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۵ (Doctor)، تسک ۰۶ (Clinic)
|
||||
|
||||
## زمان تخمینی
|
||||
۸ تا ۱۰ ساعت
|
||||
|
||||
---
|
||||
|
||||
## GET /api/v1/sms/balance
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"owner_id": 5,
|
||||
"owner_type": "doctor",
|
||||
"balance": 847
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/sms/queue
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"owner_type": "doctor",
|
||||
"owner_id": 5,
|
||||
"recipients": [
|
||||
{ "mobile": "09120671713", "appointment_uuid": "..." }
|
||||
],
|
||||
"scheduled_at": 1748000000
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 42,
|
||||
"status": "queued",
|
||||
"scheduled_at": 1748000000,
|
||||
"remaining_balance": 846
|
||||
}
|
||||
}
|
||||
|
||||
// Response 402 — موجودی ناکافی
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SMS_001", "message": "موجودی پیامک کافی نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SMS Providers — Strategy Pattern
|
||||
|
||||
```php
|
||||
interface SmsProviderInterface
|
||||
{
|
||||
public function send(string $mobile, string $message): bool;
|
||||
public function getName(): string;
|
||||
}
|
||||
|
||||
class KavehNegarProvider implements SmsProviderInterface { ... }
|
||||
class RanginehProvider implements SmsProviderInterface { ... }
|
||||
```
|
||||
|
||||
### Fallback Logic
|
||||
|
||||
```
|
||||
تلاش با Provider اول (KavehNegar):
|
||||
موفق → ثبت log و کسر موجودی
|
||||
ناموفق → تلاش با Provider دوم (Rangineh):
|
||||
موفق → ثبت log و کسر موجودی
|
||||
ناموفق → log خطا، پیامک در صف میماند برای retry
|
||||
```
|
||||
|
||||
**محیط Dev:**
|
||||
```php
|
||||
if ($this->appEnv === 'dev') {
|
||||
// OTP ثابت 12345 — بدون ارسال واقعی
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Symfony Messenger — پیادهسازی Async
|
||||
|
||||
```php
|
||||
// Message
|
||||
class SendSmsMessage
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $mobile,
|
||||
public readonly string $message,
|
||||
public readonly int $smsLogId,
|
||||
) {}
|
||||
}
|
||||
|
||||
// Handler
|
||||
class SendSmsHandler implements MessageHandlerInterface
|
||||
{
|
||||
public function __invoke(SendSmsMessage $message): void
|
||||
{
|
||||
try {
|
||||
$sent = $this->primaryProvider->send($message->mobile, $message->message);
|
||||
|
||||
if (!$sent) {
|
||||
$sent = $this->fallbackProvider->send($message->mobile, $message->message);
|
||||
}
|
||||
|
||||
$this->smsLogRepo->markSent($message->smsLogId, $sent);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('sms.send_failed', [
|
||||
'mobile' => $message->mobile,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e; // Messenger retry میکند
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Retry Config در messenger.yaml:**
|
||||
```yaml
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
delay: 5000 # 5 ثانیه
|
||||
multiplier: 2 # 5s, 10s, 20s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## قانون کسر موجودی
|
||||
|
||||
```
|
||||
قبل از ارسال:
|
||||
sms_accounts.balance >= count(recipients) ؟
|
||||
خیر → 402
|
||||
بله → ادامه
|
||||
|
||||
بعد از ارسال موفق:
|
||||
UPDATE sms_accounts SET balance = balance - 1 WHERE owner_id = X
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ساختار جدول sms_logs
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT PK | |
|
||||
| owner_type | VARCHAR(10) | `doctor` یا `clinic` |
|
||||
| owner_id | INT | |
|
||||
| mobile | VARCHAR(20) | شماره گیرنده |
|
||||
| message | TEXT | متن پیامک |
|
||||
| provider | VARCHAR(20) | `kavenegar` یا `rangineh` |
|
||||
| status | VARCHAR(10) | `queued` / `sent` / `failed` |
|
||||
| scheduled_at | INT | Unix timestamp |
|
||||
| sent_at | INT NULL | زمان ارسال واقعی |
|
||||
| error | VARCHAR(255) NULL | پیام خطا در صورت شکست |
|
||||
| created_at | INT | |
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- ارسال OTP نیز از همین سرویس استفاده میکند (با `scheduled_at=now()`)
|
||||
- برای OTP، Fallback فوری است — کاربر نمیتواند منتظر retry بماند
|
||||
- موجودی پیامک مستقل از موجودی کیف پول نماینده است
|
||||
|
||||
---
|
||||
|
||||
## سیستم تمپلیت پیامک سفارشی
|
||||
|
||||
### جریان کلی
|
||||
|
||||
```
|
||||
۱. ادمین → چند تمپلیت نمونه آموزشی میسازد (is_sample=true)
|
||||
مثلاً: "یادآوری نوبت — نمونه"
|
||||
|
||||
۲. دکتر/کلینیک → GET /api/v1/sms/sample-templates
|
||||
نمونهها را مشاهده میکند
|
||||
|
||||
۳. دکتر/کلینیک → POST /api/v1/sms/templates
|
||||
تمپلیت خودش را میسازد (status=draft)
|
||||
میتواند از نمونه الهام بگیرد یا از صفر بنویسد
|
||||
|
||||
۴. دکتر/کلینیک → PATCH /api/v1/sms/templates/{uuid}/submit
|
||||
برای تأیید ادمین ارسال میکند (status=pending_approval)
|
||||
|
||||
۵. ادمین → GET /api/v1/sms/templates?status=pending_approval
|
||||
لیست تمپلیتهای در انتظار را میبیند
|
||||
|
||||
۶. ادمین → PATCH /api/v1/sms/templates/{uuid}/approve (status=approved)
|
||||
یا PATCH /api/v1/sms/templates/{uuid}/reject (status=rejected)
|
||||
|
||||
۷. بعد از approve → سیستم SMS این تمپلیت را برای آن دکتر/کلینیک استفاده میکند
|
||||
اگر تمپلیت approved نداشت → از تمپلیت پیشفرض سیستم استفاده میشود
|
||||
```
|
||||
|
||||
### دستهبندی تمپلیتها (category)
|
||||
|
||||
| category | توضیح | متغیرهای مجاز |
|
||||
|----------|-------|--------------|
|
||||
| `appointment_reminder` | یادآوری نوبت | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}`, `{clinic_name}` |
|
||||
| `appointment_confirmed` | تأیید رزرو | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}` |
|
||||
| `appointment_cancelled` | لغو نوبت | `{patient_name}`, `{doctor_name}`, `{date}` |
|
||||
| `appointment_reminder_1h` | یادآوری ۱ ساعت قبل | `{patient_name}`, `{doctor_name}`, `{time}` |
|
||||
| `custom` | پیامک آزاد (دستی) | `{patient_name}`, `{doctor_name}` |
|
||||
|
||||
### ساختار تمپلیت و متغیرها
|
||||
|
||||
```
|
||||
متن نمونه ادمین:
|
||||
"بیمار گرامی {patient_name}، نوبت شما با {doctor_name}
|
||||
در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد."
|
||||
|
||||
دکتر میتواند تغییر دهد:
|
||||
"سلام {patient_name} عزیز! یادآوری نوبت ویزیت با دکتر {doctor_name}
|
||||
تاریخ {date} - ساعت {time}
|
||||
مطب دکتر احمدی، خیابان ولیعصر"
|
||||
```
|
||||
|
||||
متغیرها با `{variable_name}` نشان داده میشوند و هنگام ارسال با مقادیر واقعی جایگزین میشوند.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/sms/sample-templates
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — نمونه پیشفرض",
|
||||
"content": "بیمار گرامی {patient_name}، نوبت شما با {doctor_name} در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد.",
|
||||
"available_variables": ["{patient_name}", "{doctor_name}", "{date}", "{time}", "{clinic_name}"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/v1/sms/templates
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — مطب دکتر احمدی",
|
||||
"content": "سلام {patient_name} عزیز! نوبت ویزیت شما با {doctor_name} در تاریخ {date} ساعت {time}. آدرس: خیابان آزادی، مطب طبقه ۲"
|
||||
}
|
||||
|
||||
// Response 201
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — مطب دکتر احمدی",
|
||||
"content": "سلام {patient_name} عزیز!...",
|
||||
"status": "draft",
|
||||
"created_at": 1748000000
|
||||
}
|
||||
}
|
||||
|
||||
// Response 422 — متغیر نامعتبر در متن
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SMS_002", "message": "متغیر {invalid_var} در این دستهبندی مجاز نیست" }]
|
||||
}
|
||||
```
|
||||
|
||||
**اعتبارسنجی هنگام ساختن تمپلیت:**
|
||||
```
|
||||
1. category باید از لیست مجاز باشد
|
||||
2. متغیرهای داخل {} فقط از لیست available_variables مجاز category باشند
|
||||
3. طول محتوا: حداکثر 500 کاراکتر
|
||||
4. هر دکتر/کلینیک حداکثر 3 تمپلیت فعال approved برای هر category
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/sms/templates/{uuid}/submit
|
||||
|
||||
```json
|
||||
// Request — بدون body
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "pending_approval",
|
||||
"submitted_at": 1748000000
|
||||
}
|
||||
}
|
||||
|
||||
// Response 422 — تمپلیت قبلاً submitted یا approved شده
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "code": "ERR_SMS_003", "message": "تمپلیت قبلاً برای بررسی ارسال شده است" }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/sms/templates/{uuid}/approve (Admin)
|
||||
|
||||
```json
|
||||
// Request — بدون body
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "approved",
|
||||
"approved_at": 1748000000,
|
||||
"approved_by": { "uuid": "...", "name": "ادمین سیستم" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH /api/v1/sms/templates/{uuid}/reject (Admin)
|
||||
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد. لطفاً نام کامل بیمار را حذف کنید."
|
||||
}
|
||||
|
||||
// Response 200
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"uuid": "...",
|
||||
"status": "rejected",
|
||||
"rejection_reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد...",
|
||||
"rejected_at": 1748000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
بعد از reject، صاحب تمپلیت میتواند تمپلیت را ویرایش کند (status → draft) و مجدداً submit کند.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/v1/sms/templates
|
||||
|
||||
```json
|
||||
// برای دکتر/کلینیک — فقط تمپلیتهای خودش
|
||||
// برای ادمین — همه تمپلیتها با فیلتر
|
||||
|
||||
// Query params:
|
||||
// ?status=pending_approval ← ادمین برای بررسی
|
||||
// ?category=appointment_reminder
|
||||
// ?owner_type=doctor&owner_id=5
|
||||
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"category": "appointment_reminder",
|
||||
"name": "یادآوری نوبت — مطب دکتر احمدی",
|
||||
"content": "سلام {patient_name} عزیز!...",
|
||||
"status": "approved",
|
||||
"owner": { "type": "doctor", "name": "دکتر احمدی" },
|
||||
"approved_at": 1748000000,
|
||||
"created_at": 1748000000
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### منطق انتخاب تمپلیت هنگام ارسال پیامک
|
||||
|
||||
```php
|
||||
// در SmsService::getTemplateFor(ownerId, ownerType, category)
|
||||
public function getTemplateFor(int $ownerId, string $ownerType, string $category): SmsTemplate
|
||||
{
|
||||
// ابتدا تمپلیت approved خاص آن دکتر/کلینیک
|
||||
$custom = $this->templateRepo->findApproved($ownerId, $ownerType, $category);
|
||||
|
||||
if ($custom) {
|
||||
return $custom;
|
||||
}
|
||||
|
||||
// اگر نداشت → تمپلیت نمونه پیشفرض ادمین
|
||||
return $this->templateRepo->findDefaultSample($category);
|
||||
}
|
||||
|
||||
// رندر محتوای نهایی با جایگزینی متغیرها
|
||||
public function render(SmsTemplate $template, array $vars): string
|
||||
{
|
||||
return strtr($template->getContent(), array_combine(
|
||||
array_map(fn($k) => '{' . $k . '}', array_keys($vars)),
|
||||
array_values($vars)
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### جدول sms_templates
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT PK | |
|
||||
| uuid | VARCHAR(36) | |
|
||||
| owner_type | VARCHAR(10) | `doctor` / `clinic` / `admin` |
|
||||
| owner_id | INT NULL | NULL برای نمونههای ادمین |
|
||||
| category | VARCHAR(30) | appointment_reminder / ... |
|
||||
| name | VARCHAR(100) | نام قابل خواندن |
|
||||
| content | TEXT | متن با متغیرها |
|
||||
| is_sample | TINYINT(1) | 1 برای نمونههای ادمین |
|
||||
| status | VARCHAR(20) | `draft` / `pending_approval` / `approved` / `rejected` |
|
||||
| rejection_reason | TEXT NULL | دلیل رد ادمین |
|
||||
| approved_by | INT NULL | user_id ادمین تأییدکننده |
|
||||
| approved_at | INT NULL | |
|
||||
| submitted_at | INT NULL | |
|
||||
| created_at | INT | |
|
||||
| updated_at | INT | |
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_sms_tmpl_owner ON sms_templates(owner_type, owner_id);
|
||||
CREATE INDEX idx_sms_tmpl_status ON sms_templates(status);
|
||||
CREATE INDEX idx_sms_tmpl_cat ON sms_templates(category);
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# پایگاه داده — تسک ۱۸: تسویه نماینده
|
||||
|
||||
## جدول: wallet_transactions (تراکنشهای کیف پول)
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| representation_id | INT FK → representations.id NOT NULL | نماینده |
|
||||
| type | VARCHAR(10) NOT NULL | `credit` (واریز) یا `debit` (برداشت) |
|
||||
| amount | INT NOT NULL | مبلغ به ریال |
|
||||
| source | VARCHAR(20) NOT NULL | `appointment` یا `subscription` یا `settlement` |
|
||||
| source_id | INT NULL | FK به payments.id یا subscription_payments.id یا settlements.id |
|
||||
| description | VARCHAR(255) NULL | توضیح تراکنش |
|
||||
| created_at | INT NOT NULL | Unix timestamp |
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_wallet_representation ON wallet_transactions(representation_id);
|
||||
CREATE INDEX idx_wallet_type ON wallet_transactions(representation_id, type);
|
||||
CREATE INDEX idx_wallet_source ON wallet_transactions(source, source_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## جدول: settlements (درخواستهای تسویه)
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||
| representation_id | INT FK → representations.id NOT NULL | نماینده درخواستدهنده |
|
||||
| amount | INT NOT NULL | مبلغ درخواستی (ریال) |
|
||||
| card_number | VARCHAR(25) NOT NULL | شماره کارت بانکی برداشت |
|
||||
| bank_name | VARCHAR(50) NOT NULL | نام بانک |
|
||||
| status | VARCHAR(10) DEFAULT 'pending' | `pending` / `approved` / `rejected` |
|
||||
| description | VARCHAR(255) NULL | توضیح نماینده |
|
||||
| admin_note | VARCHAR(255) NULL | یادداشت ادمین هنگام تأیید/رد |
|
||||
| requested_at | INT NOT NULL | Unix timestamp — زمان درخواست |
|
||||
| resolved_at | INT NULL | Unix timestamp — زمان تأیید/رد |
|
||||
| created_at | INT NOT NULL | Unix timestamp |
|
||||
| updated_at | INT NOT NULL | Unix timestamp |
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_settlements_representation ON settlements(representation_id);
|
||||
CREATE INDEX idx_settlements_status ON settlements(status);
|
||||
CREATE UNIQUE INDEX idx_settlements_pending ON settlements(representation_id, status)
|
||||
WHERE status = 'pending';
|
||||
-- این index یکتایی یک pending در هر زمان را enforce میکند
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## فیلد balance در representations
|
||||
|
||||
جدول `representations` باید یک فیلد `balance` داشته باشد:
|
||||
|
||||
| ستون | نوع | توضیح |
|
||||
|------|-----|-------|
|
||||
| balance | INT DEFAULT 0 | موجودی کیف پول (ریال) — همیشه sync با wallet_transactions |
|
||||
|
||||
> **نکته:** `balance` باید همزمان با هر تراکنش در wallet_transactions آپدیت شود
|
||||
> تا query موجودی سریع باشد (بدون SUM روی wallet_transactions).
|
||||
|
||||
---
|
||||
|
||||
## فلوی واریز کمیسیون (هنگام پرداخت موفق)
|
||||
|
||||
```
|
||||
payments.status → 'received'
|
||||
↓
|
||||
commission = payments.amount × (representation.commission_percent / 100)
|
||||
↓
|
||||
INSERT INTO wallet_transactions (representation_id, type='credit', amount=commission, source='appointment', source_id=payment.id)
|
||||
↓
|
||||
UPDATE representations SET balance = balance + commission WHERE id = representation_id
|
||||
```
|
||||
|
||||
## فلوی تسویه (هنگام تأیید ادمین)
|
||||
|
||||
```
|
||||
PATCH /api/v1/settlement/{uuid}/approve
|
||||
↓
|
||||
بررسی: representations.balance >= settlements.amount
|
||||
↓
|
||||
INSERT INTO wallet_transactions (type='debit', amount=settlement.amount, source='settlement', source_id=settlement.id)
|
||||
↓
|
||||
UPDATE representations SET balance = balance - settlement.amount
|
||||
↓
|
||||
UPDATE settlements SET status='approved', resolved_at=NOW(), admin_note=...
|
||||
```
|
||||
@@ -0,0 +1,117 @@
|
||||
# تسک ۱۸: ماژول تسویه نماینده (Settlement)
|
||||
|
||||
## توضیح
|
||||
سیستم تسویهحساب نمایندگان — هر بار که از طریق دامنه یک نماینده نوبت پرداخت میشود،
|
||||
کمیسیون مشخصی (طبق `commission_percent`) به کیف پول نماینده واریز میشود.
|
||||
نماینده میتواند درخواست تسویه (برداشت) بدهد و ادمین آن را تأیید/رد میکند.
|
||||
|
||||
## فلوی کمیسیون (از مستند)
|
||||
|
||||
```
|
||||
بیمار → پرداخت نوبت از دامنه نماینده
|
||||
→ commission = amount × (commission_percent / 100)
|
||||
→ واریز به کیف پول نماینده (wallet_transactions)
|
||||
|
||||
دکتر → خرید اشتراک از طریق نماینده
|
||||
→ کمیسیون اشتراک → واریز به کیف پول نماینده
|
||||
```
|
||||
|
||||
## Endpoint ها
|
||||
|
||||
| متد | مسیر | توضیح | نیاز به Auth |
|
||||
|-----|------|-------|-------------|
|
||||
| GET | `/api/v1/representation/{id}/wallet` | موجودی کیف پول نماینده | بله (Admin / Owner) |
|
||||
| GET | `/api/v1/representation/{id}/wallet/transactions` | تاریخچه تراکنشهای کیف پول | بله (Admin / Owner) |
|
||||
| POST | `/api/v1/representation/{id}/settlement` | درخواست تسویه توسط نماینده | بله (Owner) |
|
||||
| GET | `/api/v1/settlements` | لیست همه درخواستهای تسویه | بله (Admin) |
|
||||
| GET | `/api/v1/settlement/{uuid}` | جزئیات یک درخواست تسویه | بله (Admin / Owner) |
|
||||
| PATCH | `/api/v1/settlement/{uuid}/approve` | تأیید تسویه توسط ادمین | بله (Admin) |
|
||||
| PATCH | `/api/v1/settlement/{uuid}/reject` | رد تسویه توسط ادمین | بله (Admin) |
|
||||
|
||||
## پیشنیازها
|
||||
- تسک ۰۱، ۰۲، ۱۵ (Payment)، ۱۶ (Representation)
|
||||
|
||||
## زمان تخمینی
|
||||
۸ تا ۱۰ ساعت
|
||||
|
||||
---
|
||||
|
||||
## نمونه Request — POST /api/v1/representation/{id}/settlement
|
||||
|
||||
```json
|
||||
{
|
||||
"amount": 5000000,
|
||||
"card_number": "6037-9999-1234-5678",
|
||||
"bank_name": "ملت",
|
||||
"description": "تسویه اسفندماه ۱۴۰۳"
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه Response — GET /api/v1/representation/{id}/wallet
|
||||
|
||||
```json
|
||||
{
|
||||
"representation_id": 3,
|
||||
"balance": 12500000,
|
||||
"total_earned": 35000000,
|
||||
"total_settled": 22500000,
|
||||
"pending_settlement": 0
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه Response — GET /api/v1/representation/{id}/wallet/transactions
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"uuid": "...",
|
||||
"type": "credit",
|
||||
"amount": 50000,
|
||||
"source": "appointment",
|
||||
"source_id": 142,
|
||||
"description": "کمیسیون نوبت #142",
|
||||
"created_at": 1748000000
|
||||
},
|
||||
{
|
||||
"uuid": "...",
|
||||
"type": "debit",
|
||||
"amount": 5000000,
|
||||
"source": "settlement",
|
||||
"source_id": 7,
|
||||
"description": "تسویه #7",
|
||||
"created_at": 1747000000
|
||||
}
|
||||
],
|
||||
"page": {
|
||||
"totalRecords": 48,
|
||||
"totalPages": 5,
|
||||
"currentPage": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## نمونه Response — GET /api/v1/settlement/{uuid}
|
||||
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"representation": { "id": 3, "uuid": "...", "label": "نمایندگی یاسوج" },
|
||||
"amount": 5000000,
|
||||
"card_number": "6037-9999-1234-5678",
|
||||
"bank_name": "ملت",
|
||||
"status": "pending",
|
||||
"description": "تسویه اسفندماه ۱۴۰۳",
|
||||
"admin_note": null,
|
||||
"requested_at": 1748000000,
|
||||
"resolved_at": null
|
||||
}
|
||||
```
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **موجودی کافی:** قبل از ثبت درخواست تسویه، موجودی کیف پول نماینده بررسی شود
|
||||
- **یک درخواست pending:** نماینده نمیتواند همزمان دو درخواست `pending` داشته باشد
|
||||
- **کارت بانکی:** شماره کارت از لیست `bank_account` نماینده باشد (نه کارت دلخواه)
|
||||
- **مبلغ حداقل:** حداقل مبلغ تسویه باید تعریف شود (مثلاً ۱۰۰,۰۰۰ ریال)
|
||||
- **واریز کمیسیون:** هنگام `payments.status = 'received'` → کمیسیون محاسبه و به wallet واریز شود
|
||||
Reference in New Issue
Block a user