feat: Implement SMS sending functionality with KavehNegar and Rangineh providers

- Add SendSmsMessage class for encapsulating SMS message data.
- Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS.
- Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates.
- Develop SendSmsHandler for handling SMS sending messages.
- Create SmsService to manage SMS dispatching and logging.
- Add UserProfileController for managing user profiles with CRUD operations.
- Implement UserProfile entity and repository for user profile data management.
- Update symfony.lock and bootstrap.php for project dependencies and environment setup.
This commit is contained in:
hamed
2026-06-09 22:00:34 +03:30
commit de1a78a235
222 changed files with 36388 additions and 0 deletions
@@ -0,0 +1,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 یا دکتر مرتبط
```
+284
View File
@@ -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}
→ لیست همه نوبت‌ها (گذشته و آینده)
→ به صورت صعودی بر اساس تاریخ مرتب‌شده
```