feat: implement staff management and subscription system

- Added StaffController for managing clinic staff, including listing, creating, updating, and toggling staff status.
- Created ClinicStaff entity and repository for staff data handling.
- Developed SubscriptionController to manage subscription plans and periods, including trial subscriptions.
- Introduced SubscriptionPlan, SubscriptionPeriod, and ClinicSubscription entities for subscription management.
- Implemented SubscriptionService for handling subscription logic, including trial activation and subscription creation from payments.
- Added necessary repositories for subscription entities to facilitate data access and manipulation.
This commit is contained in:
hamed
2026-06-14 22:10:28 +03:30
parent dcd631f503
commit b0244f28f5
53 changed files with 4434 additions and 40 deletions
+4
View File
@@ -81,3 +81,7 @@ services:
App\ClinicInvitation\Service\ClinicInvitationService:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
App\Sms\Controller\SmsWalletController:
arguments:
$appBaseUrl: '%env(APP_BASE_URL)%'
+162
View File
@@ -0,0 +1,162 @@
# Clinic Services API
مدیریت بخش‌ها و سرویس‌های کلینیک/مطب.
**نیاز به پنل:** Basic یا بالاتر (`ERR_SUBSCRIPTION_REQUIRED` اگر نداشت)
---
## GET /api/v1/service-sections
لیست بخش‌های سرویس entity جاری.
**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "clinic",
"entity_id": 5,
"name": "آزمایشگاه",
"active": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
]
}
```
---
## POST /api/v1/service-section
ایجاد بخش جدید.
**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+
**Request Body:**
```json
{ "name": "رادیولوژی" }
```
**Response 201:** ServiceSection object
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
| ERR_VALIDATION_001 | 422 | name خالی است |
---
## PATCH /api/v1/service-section/{uuid}
ویرایش بخش.
**Permission:** owner یا ROLE_ADMIN
```json
{ "name": "رادیولوژی دیجیتال", "active": true }
```
---
## DELETE /api/v1/service-section/{uuid}
حذف بخش (cascade — همه ServiceItem های آن حذف می‌شوند).
**Permission:** owner
---
## GET /api/v1/service-items/{sectionUuid}
لیست سرویس‌های یک بخش.
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"section_uuid": "...",
"staff_uuid": "...",
"staff_name": "علی محمدی",
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"active": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
]
}
```
---
## POST /api/v1/service-item
ایجاد سرویس جدید.
**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+
**Request Body:**
```json
{
"section_uuid": "...",
"name": "رادیوگرافی مستقیم",
"price_rials": 500000,
"staff_uuid": "..."
}
```
| فیلد | نوع | الزامی |
|------|-----|--------|
| section_uuid | UUID | ✅ |
| name | string | ✅ |
| price_rials | integer | ❌ (پیش‌فرض 0) |
| staff_uuid | UUID | ❌ |
**Response 201:** ServiceItem object
---
## PATCH /api/v1/service-item/{uuid}
ویرایش سرویس.
```json
{
"name": "رادیوگرافی دیجیتال",
"price_rials": 600000,
"staff_uuid": null,
"active": false
}
```
---
## DELETE /api/v1/service-item/{uuid}
حذف سرویس.
اگر سرویس در پرونده بیماری استفاده شده باشد، خطا برمی‌گرداند:
```json
{
"success": false,
"errors": [{ "code": "ERR_SERVICE_ITEM_IN_USE", "message": "این سرویس در پرونده بیمار ثبت شده است" }]
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SUBSCRIPTION_REQUIRED | 403 | نیاز به پنل Basic+ |
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_SERVICE_ITEM_IN_USE | 409 | سرویس در پرونده بیمار استفاده شده |
+83 -9
View File
@@ -10,6 +10,13 @@ Returns stats and today's schedule for the authenticated clinic owner.
**Auth:** `ROLE_CLINIC` required
### Query params
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `from` | int (unix) | start of current month | Period start for patient/revenue stats |
| `to` | int (unix) | now | Period end for patient/revenue stats |
### Response `200`
```json
@@ -26,8 +33,12 @@ Returns stats and today's schedule for the authenticated clinic owner.
"total_doctors": 5,
"today_appointments": 12,
"this_month_appointments": 87,
"pending_invitations": 2
"pending_invitations": 2,
"sms_wallet_balance": 50000,
"unique_patients_count": 34,
"revenue_period_rials": 12500000
},
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
@@ -48,8 +59,12 @@ Returns stats and today's schedule for the authenticated clinic owner.
}
```
`today_appointments` — up to 5 records, ordered by `slot_start ASC`.
`doctors` — all doctors belonging to this clinic; each includes their appointment count for today.
**Field notes:**
- `sms_wallet_balance` — current SMS wallet balance in Rials (0 if wallet not yet created)
- `unique_patients_count` — distinct patients with at least one session in the `from``to` period
- `revenue_period_rials` — sum of `final_price_rials` from all patient sessions in the period
- `today_appointments` — up to 5 records, ordered by `slot_start ASC`
- `doctors` — all doctors belonging to this clinic; each includes their appointment count for today
### Errors
@@ -65,6 +80,13 @@ Returns stats and today's schedule for the authenticated doctor.
**Auth:** `ROLE_DOCTOR` required
### Query params
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `from` | int (unix) | start of current month | Period start for patient/revenue stats |
| `to` | int (unix) | now | Period end for patient/revenue stats |
### Response `200`
```json
@@ -81,8 +103,12 @@ Returns stats and today's schedule for the authenticated doctor.
"tomorrow_appointments": 5,
"this_month_appointments": 62,
"avg_rating": 4.6,
"total_ratings": 34
"total_ratings": 34,
"sms_wallet_balance": 25000,
"unique_patients_count": 18,
"revenue_period_rials": 6800000
},
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
@@ -103,9 +129,11 @@ Returns stats and today's schedule for the authenticated doctor.
}
```
`today_appointments` — up to 10 records, ordered by `slot_start ASC`.
`avg_rating` — rounded to 1 decimal; `null` if no ratings yet.
`clinics` — all clinics the doctor belongs to.
**Field notes:**
- `today_appointments` — up to 10 records, ordered by `slot_start ASC`
- `avg_rating` — rounded to 1 decimal; `null` if no ratings yet
- `clinics` — all clinics the doctor belongs to
- `sms_wallet_balance`, `unique_patients_count`, `revenue_period_rials` — same semantics as clinic dashboard
### Errors
@@ -157,11 +185,57 @@ Returns stats for the authenticated secretary and (conditionally) today's appoin
}
```
`today_appointments` — only populated when `permissions.resources.appointments.view === true`; otherwise empty array.
`today_appointments` — up to 10 records when visible.
`today_appointments` — only populated when `permissions.resources.appointments.view === true`; up to 10 records when visible.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_FORBIDDEN_001` | 403 | Secretary relation not configured or inactive |
---
## GET /api/v1/admin/dashboard/charts
Returns time-series chart data for admin dashboard. All series are filtered to the given `from``to` window.
**Auth:** `ROLE_ADMIN` required
### Query params
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `from` | int (unix) | 30 days ago | Period start |
| `to` | int (unix) | now | Period end |
### Response `200`
```json
{
"success": true,
"data": {
"appointments_by_day": [
{ "date": "06/01", "count": 12 }
],
"revenue_by_day": [
{ "date": "06/01", "amount": 3500000 }
],
"appointment_status": [
{ "status": "confirmed", "count": 320 }
],
"top_specialties": [
{ "name": "قلب و عروق", "count": 85 }
],
"subscription_sales_by_plan": [
{ "plan": "basic", "count": 14, "revenue": 4060000 }
],
"period": { "from": 1717200000, "to": 1719792000 }
}
}
```
**Field notes:**
- `appointments_by_day` / `revenue_by_day` — one entry per calendar day in the period; days with no data appear as `count: 0` / `amount: 0`
- `appointment_status` — all-time counts, not filtered by period
- `top_specialties` — top 8 by appointment volume, all-time
- `subscription_sales_by_plan` — subscriptions created in period, grouped by plan; `revenue` sums only payments with status `received`
+278
View File
@@ -0,0 +1,278 @@
# Patient Records & Sessions API
## Overview
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the `patient_records` feature.
**Base path:** `/api/v1`
**Auth:** Bearer JWT (doctor or clinic role required)
---
## Endpoints
### List Patients
```
GET /api/v1/patients
```
Returns a paginated list of patient records belonging to the authenticated entity.
**Query params:**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `page` | int | 1 | Page number |
| `limit` | int | 20 | Items per page (1050) |
| `search` | string | — | Search by patient name or phone |
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "علی رضایی", "phone": "09123456789" },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
],
"meta": {
"totalRecords": 42,
"totalPages": 3,
"currentPage": 1
}
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No active plan with `patient_records` feature |
---
### Create Patient Record
```
POST /api/v1/patient
```
Creates a patient record for a user under the current entity. If the record already exists, returns the existing record (idempotent).
**Request body:**
```json
{
"user_uuid": "string (required)"
}
```
**Response 201:**
```json
{
"success": true,
"data": {
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "...", "phone": "..." },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | `user_uuid` missing |
| `ERR_NOT_FOUND_001` | 404 | User not found |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
### Get Patient Record
```
GET /api/v1/patient/{uuid}
```
Returns a single patient record.
**Response 200:**
```json
{
"success": true,
"data": {
"uuid": "...",
"entity_type": "doctor",
"entity_id": 5,
"user": { "uuid": "...", "fullName": "...", "phone": "..." },
"created_by_type": "doctor",
"created_by_id": 5,
"created_at": 1718375000
}
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned by caller |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
### List Patient Sessions
```
GET /api/v1/patient/{uuid}/sessions
```
Returns paginated sessions for a patient record.
**Query params:** `page`, `limit` (same as list)
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"record_uuid": "...",
"appointment_uuid": null,
"insurance_base_id": null,
"insurance_supplementary_id": null,
"visit_price_rials": 200000,
"base_insurance_discount_percent": "10.00",
"supplementary_discount_percent": "5.00",
"services_total_rials": 50000,
"final_price_rials": 230000,
"payment_method": "cash",
"notes": "...",
"created_at": 1718375000,
"updated_at": 1718375000
}
],
"meta": { "totalRecords": 8, "totalPages": 1, "currentPage": 1 }
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned by caller |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
### Create Session
```
POST /api/v1/patient/{uuid}/session
```
Creates a new visit session for a patient record.
**Request body:**
```json
{
"visit_price_rials": 200000,
"base_insurance_discount_percent": 10,
"supplementary_discount_percent": 5,
"insurance_base_id": null,
"insurance_supplementary_id": null,
"payment_method": "cash",
"notes": "...",
"services": [
{
"service_item_uuid": "...",
"staff_uuid": null
}
]
}
```
**Field notes:**
- `payment_method`: `cash` | `card` | `insurance` | `online` | `pending`
- `services`: array of service items to attach; `price_rials` is snapshot-copied from ServiceItem
- `final_price_rials` is computed: `(visit_price × (1 - base%) × (1 - supp%)) + services_total`
**Response 201:**
```json
{
"success": true,
"data": { ...session object... }
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_PATIENT_NOT_FOUND` | 404 | Record not found or not owned |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature |
---
### Update Session
```
PATCH /api/v1/session/{uuid}
```
Updates mutable fields on a session.
**Request body (all optional):**
```json
{
"notes": "...",
"payment_method": "card"
}
```
**Response 200:**
```json
{
"success": true,
"data": { ...session object... }
}
```
**Errors:**
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_SESSION_NOT_FOUND` | 404 | Session not found or not owned |
---
## Auto-Creation on Appointment Confirm
When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointment/{uuid}/status`, the system automatically:
1. Creates a `PatientRecord` for the appointment's user (if not already existing) under the doctor entity
2. Creates a blank `PatientSession` linked to the appointment
This only runs if the doctor has the `patient_records` subscription feature active.
+28
View File
@@ -218,3 +218,31 @@ Get all secretaries for a specific doctor.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the doctor |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
## محدودیت پنل اشتراکی
تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود:
| پنل | حداکثر منشی |
|-----|-------------|
| Free (بدون اشتراک) | ۱ |
| Basic | ۳ |
| Professional | ۱۰ |
اگر تعداد منشی‌های فعال به حد مجاز رسیده باشد، ایجاد منشی جدید خطای زیر را برمی‌گرداند:
```json
{
"success": false,
"errors": [
{
"code": "ERR_SECRETARY_001",
"message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد"
}
]
}
```
برای افزایش محدودیت، باید پنل را از `POST /api/v1/subscription/trial` (تریال) یا `POST /api/v1/subscription-payment` (پرداخت) ارتقاء داد.
+117
View File
@@ -298,3 +298,120 @@ Updated template with `status: "rejected"`.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 422 | Missing note |
---
## SMS Wallet
کیف پیامکی — جدا از کیف مالی، فقط برای ارسال پیامک.
### GET /api/v1/sms/wallet/balance
**Permission:** `IS_AUTHENTICATED_FULLY`
```json
{
"success": true,
"data": {
"balance_rials": 15000,
"sms_price_rials": 500,
"estimated_sms_count": 30
}
}
```
### POST /api/v1/sms/wallet/charge
شارژ کیف پیامکی از طریق درگاه پرداخت.
**Permission:** `IS_AUTHENTICATED_FULLY`
```json
{
"gateway": "mellat",
"amount_rials": 50000,
"frontend_address": "https://example.com/sms-wallet"
}
```
**Response 200:**
```json
{
"success": true,
"data": {
"payment_uuid": "...",
"redirect_url": "https://gateway...",
"order_id": "ORD-..."
}
}
```
پس از پرداخت موفق، موجودی کیف خودکار شارژ می‌شود.
### GET /api/v1/sms/wallet/logs
تراکنش‌های کیف پیامک (paginated).
**Query params:** `page`, `limit`
```json
{
"success": true,
"data": [
{
"uuid": "...",
"type": "credit",
"amount_rials": 50000,
"description": "شارژ کیف پیامک",
"created_at": 1718000000
}
],
"meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
}
```
---
## SMS Settings
### GET /api/v1/sms/settings
تنظیمات پیامک entity جاری.
**Permission:** `IS_AUTHENTICATED_FULLY`
```json
{
"success": true,
"data": {
"entity_type": "clinic",
"entity_id": 5,
"reminder_enabled": true,
"reminder_hours_before": 2,
"post_visit_enabled": false,
"post_visit_text": null,
"updated_at": 1718000000
}
}
```
### PATCH /api/v1/sms/settings
**Permission:** `IS_AUTHENTICATED_FULLY`
```json
{
"reminder_enabled": true,
"reminder_hours_before": 3,
"post_visit_enabled": true,
"post_visit_text": "از مراجعه شما سپاسگزاریم"
}
```
---
## Admin Endpoints
### GET /api/v1/admin/sms/wallet-report
**Permission:** `ROLE_ADMIN` — لیست همه کیف‌های پیامکی (paginated)
+139
View File
@@ -0,0 +1,139 @@
# Staff API
مدیریت پرسنل مطب/کلینیک (بدون حذف — فقط toggle فعال/غیرفعال).
---
## GET /api/v1/staff
دریافت لیست پرسنل entity جاری (از JWT).
**Permission:** `IS_AUTHENTICATED_FULLY` (ROLE_DOCTOR یا ROLE_CLINIC)
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "a1b2c3d4-...",
"entity_type": "clinic",
"entity_id": 5,
"full_name": "علی محمدی",
"phone": "09121234567",
"job_title": "منشی",
"address": null,
"national_code": "0012345678",
"active": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
]
}
```
---
## POST /api/v1/staff
ایجاد پرسنل جدید.
**Permission:** `IS_AUTHENTICATED_FULLY` (ROLE_DOCTOR یا ROLE_CLINIC)
**Request Body:**
```json
{
"full_name": "علی محمدی",
"phone": "09121234567",
"job_title": "منشی",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678"
}
```
| فیلد | نوع | الزامی |
|------|-----|--------|
| full_name | string | ✅ |
| phone | string | ❌ |
| job_title | string | ❌ |
| address | string | ❌ |
| national_code | string(10) | ❌ |
**Response 201:**
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-...",
"entity_type": "clinic",
"entity_id": 5,
"full_name": "علی محمدی",
"phone": "09121234567",
"job_title": "منشی",
"address": "تهران، خیابان ولیعصر",
"national_code": "0012345678",
"active": true,
"created_at": 1718000000,
"updated_at": 1718000000
}
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_VALIDATION_001 | 422 | full_name خالی است |
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
---
## PATCH /api/v1/staff/{uuid}
ویرایش اطلاعات پرسنل.
**Permission:** `IS_AUTHENTICATED_FULLY` — فقط owner یا ROLE_ADMIN
**Request Body (همه فیلدها اختیاری):**
```json
{
"full_name": "علی محمدی ویرایش‌شده",
"phone": "09129999999",
"job_title": "منشی ارشد",
"address": null,
"national_code": null
}
```
**Response 200:** همان ساختار staff object
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد |
| ERR_FORBIDDEN_001 | 403 | دسترسی ندارید |
---
## PATCH /api/v1/staff/{uuid}/toggle
تغییر وضعیت فعال/غیرفعال پرسنل (soft toggle — هیچ حذفی انجام نمی‌شود).
**Permission:** `IS_AUTHENTICATED_FULLY` — فقط owner یا ROLE_ADMIN
**Response 200:**
```json
{
"success": true,
"data": {
"uuid": "a1b2c3d4-...",
"active": false,
...
}
}
```
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد |
| ERR_FORBIDDEN_001 | 403 | دسترسی ندارید |
+214
View File
@@ -0,0 +1,214 @@
# Subscription API
مدیریت پنل‌های اشتراکی (Free / Basic / Professional).
---
## GET /api/v1/subscription/plans
لیست پنل‌ها با دوره‌های فعال (عمومی — بدون auth).
**Response 200:**
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "free",
"level": 0,
"max_secretaries": 1,
"features": { "patient_records": false, "services": false, "sms_panel": false },
"active": true,
"periods": []
},
{
"uuid": "...",
"name": "basic",
"level": 1,
"max_secretaries": 3,
"features": { "patient_records": true, "services": true, "sms_panel": false },
"active": true,
"periods": [
{
"uuid": "...",
"plan_uuid": "...",
"label": "یک ماهه",
"duration_months": 1,
"price_rials": 290000,
"is_trial": false,
"active": true,
"sort_order": 1
}
]
}
]
}
```
---
## GET /api/v1/subscription/my
اشتراک فعال کاربر جاری.
**Permission:** `IS_AUTHENTICATED_FULLY`
**Response 200:**
```json
{
"success": true,
"data": {
"subscription": {
"uuid": "...",
"plan": { "name": "basic", "level": 1, "max_secretaries": 3, "features": {...} },
"period": { "label": "یک ماهه", "duration_months": 1, "price_rials": 290000 },
"is_trial": false,
"starts_at": 1718000000,
"expires_at": 1720678400,
"days_remaining": 30,
"is_active": true
},
"used_trial": false
}
}
```
اگر اشتراک فعالی نداشت `subscription` برابر `null` است.
---
## POST /api/v1/subscription/trial
فعال‌سازی تریال رایگان (یکبار برای هر entity).
**Permission:** `IS_AUTHENTICATED_FULLY`
**Response 201:** همان ساختار ClinicSubscription
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_TRIAL_ALREADY_USED | 422 | قبلاً از تریال استفاده شده |
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است (SiteConfig: trial_enabled=0) |
| ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد |
---
## POST /api/v1/subscription-payment
شروع پرداخت اشتراک.
**Permission:** `IS_AUTHENTICATED_FULLY`
**Request Body:**
```json
{
"gateway": "mellat",
"amount_rials": 290000,
"period_uuid": "uuid-of-subscription-period",
"frontend_address": "https://example.com/payment-result"
}
```
| فیلد | نوع | الزامی |
|------|-----|--------|
| gateway | string (mellat\|sep) | ✅ |
| amount_rials | integer | ✅ |
| period_uuid | string (UUID) | ✅ |
| frontend_address | string (URL) | ❌ |
**Response 200:**
```json
{
"success": true,
"data": {
"payment_uuid": "...",
"redirect_url": "https://gateway.shaparak.ir/...",
"order_id": "ORD-XXXXXXXXXXXXXXXX"
}
}
```
---
## GET /api/v1/subscription-payment/callback/{gateway}
callback درگاه پرداخت — پس از پرداخت موفق، `ClinicSubscription` به صورت خودکار ایجاد می‌شود (بر اساس `period_uuid` ذخیره‌شده در metadata پرداخت).
---
## Admin Endpoints
### GET /api/v1/admin/subscription/plans
**Permission:** `ROLE_ADMIN` — لیست همه پنل‌ها
### POST /api/v1/admin/subscription/plan
**Permission:** `ROLE_ADMIN`
```json
{
"name": "enterprise",
"level": 3,
"max_secretaries": 20,
"features": { "patient_records": true, "services": true, "sms_panel": true }
}
```
### PATCH /api/v1/admin/subscription/plan/{uuid}
**Permission:** `ROLE_ADMIN` — ویرایش پنل (همه فیلدها اختیاری)
### POST /api/v1/admin/subscription/period
**Permission:** `ROLE_ADMIN`
```json
{
"plan_uuid": "...",
"label": "شش ماهه",
"duration_months": 6,
"price_rials": 1500000,
"is_trial": false,
"sort_order": 2
}
```
### PATCH /api/v1/admin/subscription/period/{uuid}
**Permission:** `ROLE_ADMIN` — ویرایش دوره
### DELETE /api/v1/admin/subscription/period/{uuid}
**Permission:** `ROLE_ADMIN` — غیرفعال کردن دوره (soft delete: `active=false`)
### GET /api/v1/admin/subscription/report
**Permission:** `ROLE_ADMIN`
Query params: `page`, `limit`
```json
{
"success": true,
"data": [
{
"uuid": "...",
"entity_type": "clinic",
"entity_id": 5,
"is_trial": false,
"starts_at": 1718000000,
"expires_at": 1720678400,
"plan_name": "basic",
"plan_level": 1
}
],
"meta": { "totalRecords": 50, "totalPages": 3, "currentPage": 1 }
}
```
---
## Error Codes
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_SUBSCRIPTION_REQUIRED | 403 | قابلیت نیاز به پنل Basic+ دارد |
| ERR_TRIAL_ALREADY_USED | 422 | تریال قبلاً استفاده شده |
| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است |
| ERR_SUBSCRIPTION_NOT_FOUND | 404 | پنل یافت نشد |
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260614181134 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE clinic_staff (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, full_name VARCHAR(200) NOT NULL, phone VARCHAR(20) DEFAULT NULL, job_title VARCHAR(100) DEFAULT NULL, address LONGTEXT DEFAULT NULL, national_code VARCHAR(10) DEFAULT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_CBEA5AA1D17F50A6 (uuid), INDEX idx_staff_entity_active (entity_type, entity_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE doctor_addresses DROP FOREIGN KEY `FK_doctor_addr_clinic`');
$this->addSql('ALTER TABLE doctor_addresses CHANGE type type VARCHAR(10) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE clinic_staff');
$this->addSql('ALTER TABLE doctor_addresses CHANGE type type VARCHAR(10) DEFAULT \'personal\' NOT NULL');
$this->addSql('ALTER TABLE doctor_addresses ADD CONSTRAINT `FK_doctor_addr_clinic` FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260614181629 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE clinic_subscriptions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, is_trial TINYINT NOT NULL, starts_at INT NOT NULL, expires_at INT DEFAULT NULL, created_at INT NOT NULL, plan_id INT NOT NULL, period_id INT NOT NULL, payment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_E4D1CC0FD17F50A6 (uuid), INDEX IDX_E4D1CC0FE899029B (plan_id), INDEX IDX_E4D1CC0FEC8B7ADE (period_id), INDEX IDX_E4D1CC0F4C3A3BB (payment_id), INDEX idx_subscription_entity_expires (entity_type, entity_id, expires_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE subscription_periods (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, label VARCHAR(50) NOT NULL, duration_months SMALLINT NOT NULL, price_rials INT NOT NULL, is_trial TINYINT NOT NULL, active TINYINT NOT NULL, sort_order SMALLINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, plan_id INT NOT NULL, UNIQUE INDEX UNIQ_14027839D17F50A6 (uuid), INDEX IDX_14027839E899029B (plan_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE subscription_plans (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(30) NOT NULL, level SMALLINT NOT NULL, max_secretaries SMALLINT NOT NULL, features JSON NOT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_CF5F99A2D17F50A6 (uuid), UNIQUE INDEX UNIQ_CF5F99A25E237E06 (name), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE clinic_subscriptions ADD CONSTRAINT FK_E4D1CC0FE899029B FOREIGN KEY (plan_id) REFERENCES subscription_plans (id)');
$this->addSql('ALTER TABLE clinic_subscriptions ADD CONSTRAINT FK_E4D1CC0FEC8B7ADE FOREIGN KEY (period_id) REFERENCES subscription_periods (id)');
$this->addSql('ALTER TABLE clinic_subscriptions ADD CONSTRAINT FK_E4D1CC0F4C3A3BB FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE subscription_periods ADD CONSTRAINT FK_14027839E899029B FOREIGN KEY (plan_id) REFERENCES subscription_plans (id)');
$this->addSql('ALTER TABLE payments ADD metadata JSON DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE clinic_subscriptions DROP FOREIGN KEY FK_E4D1CC0FE899029B');
$this->addSql('ALTER TABLE clinic_subscriptions DROP FOREIGN KEY FK_E4D1CC0FEC8B7ADE');
$this->addSql('ALTER TABLE clinic_subscriptions DROP FOREIGN KEY FK_E4D1CC0F4C3A3BB');
$this->addSql('ALTER TABLE subscription_periods DROP FOREIGN KEY FK_14027839E899029B');
$this->addSql('DROP TABLE clinic_subscriptions');
$this->addSql('DROP TABLE subscription_periods');
$this->addSql('DROP TABLE subscription_plans');
$this->addSql('ALTER TABLE payments DROP metadata');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\Uid\Uuid;
final class Version20260614181657 extends AbstractMigration
{
public function getDescription(): string
{
return 'Seed subscription plans and trial periods';
}
public function up(Schema $schema): void
{
$now = time();
// Plans
$freePlanUuid = Uuid::v4()->toRfc4122();
$basicPlanUuid = Uuid::v4()->toRfc4122();
$professionalPlanUuid = Uuid::v4()->toRfc4122();
$this->addSql("INSERT INTO subscription_plans (uuid, name, level, max_secretaries, features, active, created_at, updated_at) VALUES
('{$freePlanUuid}', 'free', 0, 1, '{\"patient_records\":false,\"services\":false,\"sms_panel\":false}', 1, {$now}, {$now}),
('{$basicPlanUuid}', 'basic', 1, 3, '{\"patient_records\":true,\"services\":true,\"sms_panel\":false}', 1, {$now}, {$now}),
('{$professionalPlanUuid}', 'professional', 2, 10,'{\"patient_records\":true,\"services\":true,\"sms_panel\":true}', 1, {$now}, {$now})
");
// Periods for basic plan: trial (7 days = 1/4 month), 1-month, 3-month, 12-month
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'تریال ۷ روزه', 1, 0, 1, 1, 0, {$now}, {$now} FROM subscription_plans WHERE name = 'basic'
");
// Professional trial
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'تریال ۷ روزه', 1, 0, 1, 1, 0, {$now}, {$now} FROM subscription_plans WHERE name = 'professional'
");
// Basic paid periods
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'یک ماهه', 1, 290000, 0, 1, 1, {$now}, {$now} FROM subscription_plans WHERE name = 'basic'
");
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'سه ماهه', 3, 790000, 0, 1, 2, {$now}, {$now} FROM subscription_plans WHERE name = 'basic'
");
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'یک ساله', 12, 2900000, 0, 1, 3, {$now}, {$now} FROM subscription_plans WHERE name = 'basic'
");
// Professional paid periods
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'یک ماهه', 1, 590000, 0, 1, 1, {$now}, {$now} FROM subscription_plans WHERE name = 'professional'
");
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'سه ماهه', 3, 1590000, 0, 1, 2, {$now}, {$now} FROM subscription_plans WHERE name = 'professional'
");
$this->addSql("
INSERT INTO subscription_periods (uuid, plan_id, label, duration_months, price_rials, is_trial, active, sort_order, created_at, updated_at)
SELECT '" . Uuid::v4()->toRfc4122() . "', id, 'یک ساله', 12, 5900000, 0, 1, 3, {$now}, {$now} FROM subscription_plans WHERE name = 'professional'
");
// SiteConfig: trial_enabled
$this->addSql("INSERT IGNORE INTO site_config (config_key, config_value) VALUES ('trial_enabled', '1')");
$this->addSql("INSERT IGNORE INTO site_config (config_key, config_value) VALUES ('sms_price_rials', '500')");
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM subscription_periods");
$this->addSql("DELETE FROM subscription_plans");
$this->addSql("DELETE FROM site_config WHERE config_key IN ('trial_enabled', 'sms_price_rials')");
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260614182549 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE service_items (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(200) NOT NULL, price_rials INT NOT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, section_id INT NOT NULL, staff_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_486C04AAD17F50A6 (uuid), INDEX IDX_486C04AAD823E37A (section_id), INDEX IDX_486C04AAD4D57CD (staff_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_sections (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, name VARCHAR(200) NOT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_D2E018A5D17F50A6 (uuid), INDEX idx_service_section_entity (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE service_items ADD CONSTRAINT FK_486C04AAD823E37A FOREIGN KEY (section_id) REFERENCES service_sections (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_items ADD CONSTRAINT FK_486C04AAD4D57CD FOREIGN KEY (staff_id) REFERENCES clinic_staff (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE service_items DROP FOREIGN KEY FK_486C04AAD823E37A');
$this->addSql('ALTER TABLE service_items DROP FOREIGN KEY FK_486C04AAD4D57CD');
$this->addSql('DROP TABLE service_items');
$this->addSql('DROP TABLE service_sections');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260614182950 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE sms_settings (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, reminder_enabled TINYINT NOT NULL, reminder_hours_before SMALLINT NOT NULL, post_visit_enabled TINYINT NOT NULL, post_visit_text LONGTEXT DEFAULT NULL, updated_at INT NOT NULL, UNIQUE INDEX uniq_sms_settings_entity (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE sms_wallet_transactions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(10) NOT NULL, amount_rials INT NOT NULL, description VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, wallet_id INT NOT NULL, payment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_377BA1E0D17F50A6 (uuid), INDEX IDX_377BA1E0712520F3 (wallet_id), INDEX IDX_377BA1E04C3A3BB (payment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE sms_wallets (id INT AUTO_INCREMENT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, balance_rials INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX uniq_sms_wallet_entity (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE sms_wallet_transactions ADD CONSTRAINT FK_377BA1E0712520F3 FOREIGN KEY (wallet_id) REFERENCES sms_wallets (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE sms_wallet_transactions ADD CONSTRAINT FK_377BA1E04C3A3BB FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE sms_wallet_transactions DROP FOREIGN KEY FK_377BA1E0712520F3');
$this->addSql('ALTER TABLE sms_wallet_transactions DROP FOREIGN KEY FK_377BA1E04C3A3BB');
$this->addSql('DROP TABLE sms_settings');
$this->addSql('DROP TABLE sms_wallet_transactions');
$this->addSql('DROP TABLE sms_wallets');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260614183527 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE patient_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, created_by_type VARCHAR(15) NOT NULL, created_by_id INT NOT NULL, created_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_C1FE0DADD17F50A6 (uuid), INDEX IDX_C1FE0DADA76ED395 (user_id), UNIQUE INDEX uniq_patient_record (entity_type, entity_id, user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE patient_sessions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, insurance_base_id INT DEFAULT NULL, insurance_supplementary_id INT DEFAULT NULL, visit_price_rials INT NOT NULL, base_insurance_discount_percent NUMERIC(5, 2) NOT NULL, supplementary_discount_percent NUMERIC(5, 2) NOT NULL, services_total_rials INT NOT NULL, final_price_rials INT NOT NULL, payment_method VARCHAR(15) NOT NULL, notes LONGTEXT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, record_id INT NOT NULL, appointment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_ADE5C5B6D17F50A6 (uuid), INDEX IDX_ADE5C5B64DFD750C (record_id), INDEX IDX_ADE5C5B6E5B533F9 (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE session_services (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, price_rials INT NOT NULL, created_at INT NOT NULL, session_id INT NOT NULL, service_item_id INT NOT NULL, staff_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_F89E8101D17F50A6 (uuid), INDEX IDX_F89E8101613FECDF (session_id), INDEX IDX_F89E8101DDEB00C2 (service_item_id), INDEX IDX_F89E8101D4D57CD (staff_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE patient_records ADD CONSTRAINT FK_C1FE0DADA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE patient_sessions ADD CONSTRAINT FK_ADE5C5B64DFD750C FOREIGN KEY (record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE patient_sessions ADD CONSTRAINT FK_ADE5C5B6E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE session_services ADD CONSTRAINT FK_F89E8101613FECDF FOREIGN KEY (session_id) REFERENCES patient_sessions (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE session_services ADD CONSTRAINT FK_F89E8101DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE session_services ADD CONSTRAINT FK_F89E8101D4D57CD FOREIGN KEY (staff_id) REFERENCES clinic_staff (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE patient_records DROP FOREIGN KEY FK_C1FE0DADA76ED395');
$this->addSql('ALTER TABLE patient_sessions DROP FOREIGN KEY FK_ADE5C5B64DFD750C');
$this->addSql('ALTER TABLE patient_sessions DROP FOREIGN KEY FK_ADE5C5B6E5B533F9');
$this->addSql('ALTER TABLE session_services DROP FOREIGN KEY FK_F89E8101613FECDF');
$this->addSql('ALTER TABLE session_services DROP FOREIGN KEY FK_F89E8101DDEB00C2');
$this->addSql('ALTER TABLE session_services DROP FOREIGN KEY FK_F89E8101D4D57CD');
$this->addSql('DROP TABLE patient_records');
$this->addSql('DROP TABLE patient_sessions');
$this->addSql('DROP TABLE session_services');
}
}
+39 -20
View File
@@ -1687,34 +1687,37 @@ class AdminApiController extends BaseController
// ── Dashboard Charts ──────────────────────────────────────────────────────
#[Route('/api/v1/admin/dashboard/charts', methods: ['GET'])]
public function dashboardCharts(): JsonResponse
public function dashboardCharts(Request $request): JsonResponse
{
$conn = $this->em->getConnection();
$days = 30;
$now = time();
$start = $now - ($days * 86400);
$conn = $this->em->getConnection();
$now = time();
$from = $request->query->get('from') ? (int) $request->query->get('from') : $now - (30 * 86400);
$to = $request->query->get('to') ? (int) $request->query->get('to') : $now;
$apptRows = $conn->fetchAllAssociative(
'SELECT DATE(FROM_UNIXTIME(slot_start)) AS d, COUNT(*) AS cnt
FROM appointments WHERE slot_start >= :s GROUP BY d ORDER BY d',
['s' => $start]
FROM appointments WHERE slot_start BETWEEN :s AND :e GROUP BY d ORDER BY d',
['s' => $from, 'e' => $to]
);
$apptMap = array_column($apptRows, 'cnt', 'd');
$revRows = $conn->fetchAllAssociative(
'SELECT DATE(FROM_UNIXTIME(created_at)) AS d, COALESCE(SUM(amount_rials), 0) AS total
FROM payments WHERE status = :st AND created_at >= :s GROUP BY d ORDER BY d',
['st' => 'received', 's' => $start]
FROM payments WHERE status = :st AND created_at BETWEEN :s AND :e GROUP BY d ORDER BY d',
['st' => 'received', 's' => $from, 'e' => $to]
);
$revMap = array_column($revRows, 'total', 'd');
$appt30d = [];
$rev30d = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = date('Y-m-d', $now - $i * 86400);
$shortDate = date('m/d', $now - $i * 86400);
$appt30d[] = ['date' => $shortDate, 'count' => (int)($apptMap[$date] ?? 0)];
$rev30d[] = ['date' => $shortDate, 'amount' => (int)($revMap[$date] ?? 0)];
$days = max(1, (int) ceil(($to - $from) / 86400));
$apptByDay = [];
$revByDay = [];
for ($i = 0; $i < $days; $i++) {
$ts = $from + $i * 86400;
$date = date('Y-m-d', $ts);
$shortDate = date('m/d', $ts);
$apptByDay[] = ['date' => $shortDate, 'count' => (int)($apptMap[$date] ?? 0)];
$revByDay[] = ['date' => $shortDate, 'amount' => (int)($revMap[$date] ?? 0)];
}
$statusRows = $conn->fetchAllAssociative(
@@ -1729,11 +1732,27 @@ class AdminApiController extends BaseController
GROUP BY s.id, s.name ORDER BY cnt DESC LIMIT 8'
);
$subRows = $conn->fetchAllAssociative(
'SELECT sp.name AS plan_name, COUNT(cs.id) AS cnt, COALESCE(SUM(p.amount_rials), 0) AS revenue
FROM clinic_subscriptions cs
JOIN subscription_plans sp ON sp.id = cs.plan_id
LEFT JOIN payments p ON p.id = cs.payment_id AND p.status = :st
WHERE cs.created_at BETWEEN :s AND :e
GROUP BY sp.id, sp.name ORDER BY cnt DESC',
['st' => 'received', 's' => $from, 'e' => $to]
);
return $this->success([
'appointments_30d' => $appt30d,
'revenue_30d' => $rev30d,
'appointment_status' => array_map(fn($r) => ['status' => $r['status'], 'count' => (int) $r['cnt']], $statusRows),
'top_specialties' => array_map(fn($r) => ['name' => $r['name'], 'count' => (int) $r['cnt']], $specRows),
'appointments_by_day' => $apptByDay,
'revenue_by_day' => $revByDay,
'appointment_status' => array_map(fn($r) => ['status' => $r['status'], 'count' => (int) $r['cnt']], $statusRows),
'top_specialties' => array_map(fn($r) => ['name' => $r['name'], 'count' => (int) $r['cnt']], $specRows),
'subscription_sales_by_plan' => array_map(fn($r) => [
'plan' => $r['plan_name'],
'count' => (int) $r['cnt'],
'revenue' => (int) $r['revenue'],
], $subRows),
'period' => ['from' => $from, 'to' => $to],
]);
}
}
@@ -7,6 +7,7 @@ use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Service\PatientService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\OptimisticLockException;
@@ -24,6 +25,7 @@ class AppointmentController extends BaseController
private readonly AppointmentRepository $appointmentRepo,
private readonly DoctorRepository $doctorRepo,
private readonly SlotCalculatorService $slotCalculator,
private readonly PatientService $patientService,
) {}
// ── Public: available slots ───────────────────────────────────────────────
@@ -426,6 +428,10 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409);
}
if ($newStatus === Appointment::STATUS_CONFIRMED) {
$this->patientService->autoCreateOnAppointmentConfirm($appointment);
}
return $this->success(['data' => $appointment->toArray()]);
}
}
@@ -0,0 +1,241 @@
<?php
namespace App\ClinicService\Controller;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Repository\ServiceSectionRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Staff\Repository\ClinicStaffRepository;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ClinicServiceController extends BaseController
{
public function __construct(
private readonly ServiceSectionRepository $sectionRepo,
private readonly ServiceItemRepository $itemRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly SubscriptionService $subscriptionService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
) {}
// ── Service Sections ─────────────────────────────────────────────────────
#[Route('/api/v1/service-sections', methods: ['GET'])]
public function listSections(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertServicesGate($entityType, $entityId);
$sections = array_map(
fn(ServiceSection $s) => $s->toArray(),
$this->sectionRepo->findByEntity($entityType, $entityId)
);
return $this->success($sections);
}
#[Route('/api/v1/service-section', methods: ['POST'])]
public function createSection(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertServicesGate($entityType, $entityId);
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name الزامی است', 422);
}
$section = new ServiceSection($entityType, $entityId, $name);
$this->sectionRepo->save($section);
return $this->success($section->toArray(), 201);
}
#[Route('/api/v1/service-section/{uuid}', methods: ['PATCH'])]
public function updateSection(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertServicesGate($entityType, $entityId);
$section = $this->sectionRepo->findByUuid($uuid);
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name']) && trim($data['name']) !== '') {
$section->setName(trim($data['name']));
}
if (isset($data['active'])) {
$section->setActive((bool) $data['active']);
}
$this->sectionRepo->save($section);
return $this->success($section->toArray());
}
#[Route('/api/v1/service-section/{uuid}', methods: ['DELETE'])]
public function deleteSection(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertServicesGate($entityType, $entityId);
$section = $this->sectionRepo->findByUuid($uuid);
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
$this->sectionRepo->remove($section);
return $this->success(['message' => 'بخش حذف شد']);
}
// ── Service Items ────────────────────────────────────────────────────────
#[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])]
public function listItems(string $sectionUuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$section = $this->sectionRepo->findByUuid($sectionUuid);
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
$items = array_map(
fn(ServiceItem $i) => $i->toArray(),
$this->itemRepo->findBySection($section)
);
return $this->success($items);
}
#[Route('/api/v1/service-item', methods: ['POST'])]
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertServicesGate($entityType, $entityId);
$data = json_decode($request->getContent(), true) ?? [];
$sectionUuid = $data['section_uuid'] ?? '';
$name = trim($data['name'] ?? '');
if ($name === '' || $sectionUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'section_uuid و name الزامی هستند', 422);
}
$section = $this->sectionRepo->findByUuid($sectionUuid);
if ($section === null || !$this->ownsSection($section, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, 'بخش یافت نشد', 404);
}
$item = new ServiceItem($section, $name, (int) ($data['price_rials'] ?? 0));
if (!empty($data['staff_uuid'])) {
$staff = $this->staffRepo->findByUuid($data['staff_uuid']);
if ($staff !== null) {
$item->setStaff($staff);
}
}
$this->itemRepo->save($item);
return $this->success($item->toArray(), 201);
}
#[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])]
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$item = $this->itemRepo->findByUuid($uuid);
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); }
if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); }
if (isset($data['active'])) { $item->setActive((bool) $data['active']); }
if (array_key_exists('staff_uuid', $data)) {
$staff = $data['staff_uuid'] ? $this->staffRepo->findByUuid($data['staff_uuid']) : null;
$item->setStaff($staff);
}
$this->itemRepo->save($item);
return $this->success($item->toArray());
}
#[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])]
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$item = $this->itemRepo->findByUuid($uuid);
if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404);
}
try {
$this->itemRepo->remove($item);
} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException) {
return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, ErrorCodes::message(ErrorCodes::ERR_SERVICE_ITEM_IN_USE), 409);
}
return $this->success(['message' => 'سرویس حذف شد']);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
private function assertServicesGate(string $entityType, ?int $entityId): void
{
if ($entityId === null) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'services')) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
}
private function ownsSection(ServiceSection $section, string $entityType, ?int $entityId): bool
{
return $entityId !== null
&& $section->getEntityType() === $entityType
&& $section->getEntityId() === $entityId;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Staff\Entity\ClinicStaff;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
#[ORM\Table(name: 'service_items')]
class ServiceItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: ServiceSection::class, inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ServiceSection $section;
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?ClinicStaff $staff = null;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(ServiceSection $section, string $name, int $priceRials = 0)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->section = $section;
$this->name = $name;
$this->priceRials = $priceRials;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getSection(): ServiceSection { return $this->section; }
public function getStaff(): ?ClinicStaff { return $this->staff; }
public function getName(): string { return $this->name; }
public function getPriceRials(): int { return $this->priceRials; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; }
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'section_uuid' => $this->section->getUuid(),
'staff_uuid' => $this->staff?->getUuid(),
'staff_name' => $this->staff?->getFullName(),
'name' => $this->name,
'price_rials' => $this->priceRials,
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\ClinicService\Entity;
use App\ClinicService\Repository\ServiceSectionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ServiceSectionRepository::class)]
#[ORM\Table(name: 'service_sections')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_section_entity')]
class ServiceSection
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: ServiceItem::class, mappedBy: 'section', cascade: ['remove'])]
private Collection $items;
public function __construct(string $entityType, int $entityId, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
$this->items = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getName(): string { return $this->name; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'name' => $this->name,
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ServiceItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServiceItem::class);
}
public function findByUuid(string $uuid): ?ServiceItem
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findBySection(ServiceSection $section): array
{
return $this->createQueryBuilder('i')
->where('i.section = :section')
->setParameter('section', $section)
->orderBy('i.name', 'ASC')
->getQuery()
->getResult();
}
public function save(ServiceItem $item): void
{
$this->getEntityManager()->persist($item);
$this->getEntityManager()->flush();
}
public function remove(ServiceItem $item): void
{
$this->getEntityManager()->remove($item);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ServiceSection;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ServiceSectionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServiceSection::class);
}
public function findByUuid(string $uuid): ?ServiceSection
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByEntity(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('s')
->where('s.entityType = :type')
->andWhere('s.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('s.name', 'ASC')
->getQuery()
->getResult();
}
public function save(ServiceSection $section): void
{
$this->getEntityManager()->persist($section);
$this->getEntityManager()->flush();
}
public function remove(ServiceSection $section): void
{
$this->getEntityManager()->remove($section);
$this->getEntityManager()->flush();
}
}
@@ -5,11 +5,15 @@ namespace App\Dashboard\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsWalletService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -21,24 +25,30 @@ class DashboardController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly EntityManagerInterface $em,
private readonly SmsWalletService $smsWalletService,
private readonly PatientRecordRepository $patientRecordRepo,
private readonly PatientSessionRepository $patientSessionRepo,
) {}
// ── Clinic Dashboard ────────────────────────────────────────────────────
#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
#[IsGranted('ROLE_CLINIC')]
public function clinic(#[CurrentUser] User $user): JsonResponse
public function clinic(Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
}
$clinicId = $clinic->getId();
$clinicId = $clinic->getId();
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
$monthStart = strtotime('first day of this month midnight');
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
// آمار نوبت‌های امروز و این ماه
$stats = $this->em->createQuery('
SELECT
@@ -114,6 +124,10 @@ class DashboardController extends BaseController
'todayEnd' => $todayEnd,
])->getArrayResult();
$smsBalance = $this->smsWalletService->getBalance('clinic', $clinicId);
$uniquePatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, $from, $to);
$revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to);
return $this->success([
'clinic' => [
'uuid' => $clinic->getUuid(),
@@ -126,7 +140,11 @@ class DashboardController extends BaseController
'today_appointments' => (int) ($stats['today_appointments'] ?? 0),
'this_month_appointments' => $monthCount,
'pending_invitations' => $pendingInvitations,
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'revenue_period_rials' => $revenuePeriod,
],
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'doctors' => $doctors,
]);
@@ -136,7 +154,7 @@ class DashboardController extends BaseController
#[Route('/api/v1/dashboard/doctor', methods: ['GET'])]
#[IsGranted('ROLE_DOCTOR')]
public function doctor(#[CurrentUser] User $user): JsonResponse
public function doctor(Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null) {
@@ -150,6 +168,9 @@ class DashboardController extends BaseController
$tmrEnd = strtotime('tomorrow midnight') + 86399;
$monthStart = strtotime('first day of this month midnight');
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
// آمار
$todayCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
@@ -197,6 +218,10 @@ class DashboardController extends BaseController
WHERE d.id = :doctorId
')->setParameter('doctorId', $doctorId)->getArrayResult();
$smsBalance = $this->smsWalletService->getBalance('doctor', $doctorId);
$uniquePatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
$revenuePeriod = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
return $this->success([
'doctor' => [
'uuid' => $doctor->getUuid(),
@@ -209,7 +234,11 @@ class DashboardController extends BaseController
'this_month_appointments' => $monthCount,
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'revenue_period_rials' => $revenuePeriod,
],
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'clinics' => $clinics,
]);
@@ -0,0 +1,193 @@
<?php
namespace App\Patient\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Entity\PatientRecord;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Service\PatientService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PatientController extends BaseController
{
public function __construct(
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly PatientService $patientService,
private readonly SubscriptionService $subscriptionService,
private readonly UserRepository $userRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
) {}
#[Route('/api/v1/patients', methods: ['GET'])]
public function list(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$search = $request->query->get('search') ?: null;
$records = $this->recordRepo->findByEntity($entityType, $entityId, $page, $limit, $search);
$total = $this->recordRepo->countByEntity($entityType, $entityId, $search);
return $this->paginated(
array_map(fn(PatientRecord $r) => $r->toArray(), $records),
$total,
$page,
$limit
);
}
#[Route('/api/v1/patient', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$data = json_decode($request->getContent(), true) ?? [];
$userUuid = trim($data['user_uuid'] ?? '');
if ($userUuid === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'user_uuid الزامی است', 422);
}
$patient = $this->userRepo->findByUuid($userUuid);
if ($patient === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربر یافت نشد', 404);
}
$existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($existing !== null) {
return $this->success($existing->toArray());
}
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
$this->recordRepo->save($record);
return $this->success($record->toArray(), 201);
}
#[Route('/api/v1/patient/{uuid}', methods: ['GET'])]
public function show(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
return $this->success($record->toArray());
}
#[Route('/api/v1/patient/{uuid}/sessions', methods: ['GET'])]
public function sessions(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$sessions = $this->sessionRepo->findByRecord($record, $page, $limit);
$total = $this->sessionRepo->countByRecord($record);
return $this->paginated(
array_map(fn($s) => $s->toArray(), $sessions),
$total,
$page,
$limit
);
}
#[Route('/api/v1/patient/{uuid}/session', methods: ['POST'])]
public function createSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$this->assertPatientGate($entityType, $entityId);
$record = $this->recordRepo->findByUuid($uuid);
if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$session = $this->patientService->createSession($record, $data, $entityType, $entityId);
return $this->success($session->toArray(), 201);
}
#[Route('/api/v1/session/{uuid}', methods: ['PATCH'])]
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_SESSION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SESSION_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
if (isset($data['payment_method'])) { $session->setPaymentMethod($data['payment_method']); }
$this->sessionRepo->save($session);
return $this->success($session->toArray());
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
private function assertPatientGate(string $entityType, ?int $entityId): void
{
if ($entityId === null) {
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403);
}
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
}
private function ownsRecord($record, string $entityType, ?int $entityId): bool
{
return $entityId !== null
&& $record->getEntityType() === $entityType
&& $record->getEntityId() === $entityId;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Patient\Entity;
use App\Auth\Entity\User;
use App\Patient\Repository\PatientRecordRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: PatientRecordRepository::class)]
#[ORM\Table(name: 'patient_records')]
#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
class PatientRecord
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
private User $user;
#[ORM\Column(name: 'created_by_type', type: 'string', length: 15)]
private string $createdByType;
#[ORM\Column(name: 'created_by_id', type: 'integer')]
private int $createdById;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\OneToMany(targetEntity: PatientSession::class, mappedBy: 'record', cascade: ['remove'])]
private Collection $sessions;
public function __construct(string $entityType, int $entityId, User $user, string $createdByType, int $createdById)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->user = $user;
$this->createdByType = $createdByType;
$this->createdById = $createdById;
$this->createdAt = time();
$this->sessions = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getUser(): User { return $this->user; }
public function getCreatedByType(): string { return $this->createdByType; }
public function getCreatedById(): int { return $this->createdById; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'user_uuid' => $this->user->getUuid(),
'user_name' => $this->user->getRealName(),
'user_mobile' => $this->user->getMobileNumber(),
'created_by_type' => $this->createdByType,
'created_at' => $this->createdAt,
];
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\Patient\Entity;
use App\Appointment\Entity\Appointment;
use App\Patient\Repository\PatientSessionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: PatientSessionRepository::class)]
#[ORM\Table(name: 'patient_sessions')]
class PatientSession
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientRecord::class, inversedBy: 'sessions')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private PatientRecord $record;
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Appointment $appointment = null;
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
#[ORM\Column(name: 'insurance_supplementary_id', type: 'integer', nullable: true)]
private ?int $insuranceSupplementaryId = null;
#[ORM\Column(name: 'visit_price_rials', type: 'integer')]
private int $visitPriceRials = 0;
#[ORM\Column(name: 'base_insurance_discount_percent', type: 'decimal', precision: 5, scale: 2)]
private string $baseInsuranceDiscountPercent = '0.00';
#[ORM\Column(name: 'supplementary_discount_percent', type: 'decimal', precision: 5, scale: 2)]
private string $supplementaryDiscountPercent = '0.00';
#[ORM\Column(name: 'services_total_rials', type: 'integer')]
private int $servicesTotalRials = 0;
#[ORM\Column(name: 'final_price_rials', type: 'integer')]
private int $finalPriceRials = 0;
#[ORM\Column(name: 'payment_method', type: 'string', length: 15)]
private string $paymentMethod = 'pending';
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notes = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: SessionService::class, mappedBy: 'session', cascade: ['remove'])]
private Collection $services;
public function __construct(PatientRecord $record, ?Appointment $appointment = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->appointment = $appointment;
$this->createdAt = time();
$this->updatedAt = time();
$this->services = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getRecord(): PatientRecord { return $this->record; }
public function getAppointment(): ?Appointment { return $this->appointment; }
public function getVisitPriceRials(): int { return $this->visitPriceRials; }
public function getBaseInsuranceDiscountPercent(): float { return (float) $this->baseInsuranceDiscountPercent; }
public function getSupplementaryDiscountPercent(): float { return (float) $this->supplementaryDiscountPercent; }
public function getServicesTotalRials(): int { return $this->servicesTotalRials; }
public function getFinalPriceRials(): int { return $this->finalPriceRials; }
public function getPaymentMethod(): string { return $this->paymentMethod; }
public function getNotes(): ?string { return $this->notes; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setInsuranceBaseId(?int $id): self { $this->insuranceBaseId = $id; $this->updatedAt = time(); return $this; }
public function setInsuranceSupplementaryId(?int $id): self { $this->insuranceSupplementaryId = $id; $this->updatedAt = time(); return $this; }
public function setVisitPriceRials(int $v): self { $this->visitPriceRials = $v; $this->updatedAt = time(); return $this; }
public function setBaseInsuranceDiscountPercent(float $v): self { $this->baseInsuranceDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setSupplementaryDiscountPercent(float $v): self { $this->supplementaryDiscountPercent = (string) $v; $this->updatedAt = time(); return $this; }
public function setServicesTotalRials(int $v): self { $this->servicesTotalRials = $v; $this->updatedAt = time(); return $this; }
public function setFinalPriceRials(int $v): self { $this->finalPriceRials = $v; $this->updatedAt = time(); return $this; }
public function setPaymentMethod(string $v): self { $this->paymentMethod = $v; $this->updatedAt = time(); return $this; }
public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'record_uuid' => $this->record->getUuid(),
'appointment_uuid' => $this->appointment?->getUuid(),
'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'visit_price_rials' => $this->visitPriceRials,
'base_insurance_discount_percent' => (float) $this->baseInsuranceDiscountPercent,
'supplementary_discount_percent' => (float) $this->supplementaryDiscountPercent,
'services_total_rials' => $this->servicesTotalRials,
'final_price_rials' => $this->finalPriceRials,
'payment_method' => $this->paymentMethod,
'notes' => $this->notes,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Patient\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Patient\Repository\SessionServiceRepository;
use App\Staff\Entity\ClinicStaff;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SessionServiceRepository::class)]
#[ORM\Table(name: 'session_services')]
class SessionService
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientSession::class, inversedBy: 'services')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private PatientSession $session;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', nullable: false, onDelete: 'RESTRICT')]
private ServiceItem $serviceItem;
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?ClinicStaff $staff = null;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(PatientSession $session, ServiceItem $serviceItem, ?ClinicStaff $staff = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->session = $session;
$this->serviceItem = $serviceItem;
$this->staff = $staff;
$this->priceRials = $serviceItem->getPriceRials();
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getSession(): PatientSession { return $this->session; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getStaff(): ?ClinicStaff { return $this->staff; }
public function getPriceRials(): int { return $this->priceRials; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'service_item_uuid' => $this->serviceItem->getUuid(),
'service_name' => $this->serviceItem->getName(),
'staff_uuid' => $this->staff?->getUuid(),
'staff_name' => $this->staff?->getFullName(),
'price_rials' => $this->priceRials,
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Patient\Repository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientRecord;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PatientRecordRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PatientRecord::class);
}
public function findByUuid(string $uuid): ?PatientRecord
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByEntityAndUser(string $entityType, int $entityId, User $user): ?PatientRecord
{
return $this->findOneBy([
'entityType' => $entityType,
'entityId' => $entityId,
'user' => $user,
]);
}
public function findByEntity(string $entityType, int $entityId, int $page = 1, int $limit = 20, ?string $search = null): array
{
$qb = $this->createQueryBuilder('r')
->join('r.user', 'u')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('r.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
if ($search !== null && $search !== '') {
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search')
->setParameter('search', '%' . $search . '%');
}
return $qb->getQuery()->getResult();
}
public function countByEntity(string $entityType, int $entityId, ?string $search = null): int
{
$qb = $this->createQueryBuilder('r')
->select('COUNT(r.id)')
->join('r.user', 'u')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId);
if ($search !== null && $search !== '') {
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search')
->setParameter('search', '%' . $search . '%');
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countUnique(string $entityType, int $entityId, int $from, int $to): int
{
return (int) $this->createQueryBuilder('r')
->select('COUNT(DISTINCT r.user)')
->join('r.sessions', 's')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->andWhere('s.createdAt BETWEEN :from AND :to')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getSingleScalarResult();
}
public function save(PatientRecord $record): void
{
$this->getEntityManager()->persist($record);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PatientSessionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PatientSession::class);
}
public function findByUuid(string $uuid): ?PatientSession
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByRecord(PatientRecord $record, int $page = 1, int $limit = 20): array
{
return $this->createQueryBuilder('s')
->where('s.record = :record')
->setParameter('record', $record)
->orderBy('s.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countByRecord(PatientRecord $record): int
{
return (int) $this->createQueryBuilder('s')
->select('COUNT(s.id)')
->where('s.record = :record')
->setParameter('record', $record)
->getQuery()
->getSingleScalarResult();
}
public function sumRevenue(string $entityType, int $entityId, int $from, int $to): int
{
$result = $this->createQueryBuilder('s')
->select('SUM(s.finalPriceRials)')
->join('s.record', 'r')
->where('r.entityType = :type')
->andWhere('r.entityId = :id')
->andWhere('s.createdAt BETWEEN :from AND :to')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getSingleScalarResult();
return (int) ($result ?? 0);
}
public function save(PatientSession $session): void
{
$this->getEntityManager()->persist($session);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\SessionService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SessionServiceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SessionService::class);
}
public function save(SessionService $service): void
{
$this->getEntityManager()->persist($service);
$this->getEntityManager()->flush();
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
namespace App\Patient\Service;
use App\Appointment\Entity\Appointment;
use App\Auth\Repository\UserRepository;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionService;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Repository\SessionServiceRepository;
use App\Staff\Repository\ClinicStaffRepository;
use App\Subscription\Service\SubscriptionService;
class PatientService
{
public function __construct(
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly SessionServiceRepository $sessionServiceRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserRepository $userRepo,
private readonly SubscriptionService $subscriptionService,
) {}
public function calculateFinalPrice(int $visitPrice, float $baseDiscount, float $suppDiscount, array $serviceItems): array
{
$afterBase = $visitPrice * (1 - $baseDiscount / 100);
$afterSupp = $afterBase * (1 - $suppDiscount / 100);
$servicesTotal = array_sum(array_column($serviceItems, 'price_rials'));
return [
'services_total_rials' => (int) $servicesTotal,
'final_price_rials' => (int) round($afterSupp) + (int) $servicesTotal,
];
}
public function autoCreateOnAppointmentConfirm(Appointment $appointment): void
{
$doctor = $appointment->getDoctor();
$entityType = 'doctor';
$entityId = $doctor->getId();
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'patient_records')) {
return;
}
$patient = $appointment->getUser();
$record = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($record === null) {
$record = new PatientRecord($entityType, $entityId, $patient, 'system', $doctor->getId());
$this->recordRepo->save($record);
}
$session = new PatientSession($record, $appointment);
$this->sessionRepo->save($session);
}
public function createSession(
PatientRecord $record,
array $data,
string $entityType,
int $entityId
): PatientSession {
$session = new PatientSession($record);
if (!empty($data['appointment_uuid'])) {
// appointment را از بیرون resolve می‌کنند و session را ست می‌کنند
}
$session->setInsuranceBaseId(isset($data['insurance_base_id']) ? (int) $data['insurance_base_id'] : null);
$session->setInsuranceSupplementaryId(isset($data['insurance_supplementary_id']) ? (int) $data['insurance_supplementary_id'] : null);
$session->setVisitPriceRials((int) ($data['visit_price_rials'] ?? 0));
$session->setBaseInsuranceDiscountPercent((float) ($data['base_insurance_discount_percent'] ?? 0));
$session->setSupplementaryDiscountPercent((float) ($data['supplementary_discount_percent'] ?? 0));
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
// جمع‌آوری service items
$serviceItemsData = [];
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($item !== null) {
$serviceItemsData[] = ['price_rials' => $item->getPriceRials()];
}
}
$priceCalc = $this->calculateFinalPrice(
$session->getVisitPriceRials(),
$session->getBaseInsuranceDiscountPercent(),
$session->getSupplementaryDiscountPercent(),
$serviceItemsData
);
$session->setServicesTotalRials($priceCalc['services_total_rials']);
$session->setFinalPriceRials($priceCalc['final_price_rials']);
$this->sessionRepo->save($session);
// ثبت session services
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');
if ($item === null) {
continue;
}
$staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null;
$ss = new SessionService($session, $item, $staff);
$this->sessionServiceRepo->save($ss);
}
return $session;
}
}
+54 -1
View File
@@ -5,6 +5,8 @@ namespace App\Payment\Controller;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Payment\Entity\Payment;
use App\Payment\Gateway\MellatGateway;
use App\Payment\Gateway\SepGateway;
@@ -12,6 +14,8 @@ use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\CircuitBreakerService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsWalletService;
use App\Subscription\Service\SubscriptionService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -35,6 +39,10 @@ class PaymentController extends BaseController
private readonly MellatGateway $mellat,
private readonly SepGateway $sep,
private readonly CircuitBreakerService $circuitBreaker,
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -256,6 +264,12 @@ class PaymentController extends BaseController
$payment->setReferenceId($result->referenceId);
$this->paymentRepo->save($payment);
if ($payment->getType() === Payment::TYPE_SUBSCRIPTION) {
$this->handleSubscriptionActivation($payment);
} elseif ($payment->getType() === Payment::TYPE_SMS_WALLET) {
$this->handleSmsWalletCharge($payment);
}
return $this->redirectToFrontend($payment, true);
}
@@ -380,7 +394,11 @@ class PaymentController extends BaseController
return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503);
}
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
$periodUuid = trim($data['period_uuid'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress);
if ($periodUuid !== '') {
$payment->setMetadata(['period_uuid' => $periodUuid]);
}
$this->paymentRepo->save($payment);
$callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
@@ -540,6 +558,41 @@ class PaymentController extends BaseController
return false;
}
private function handleSmsWalletCharge(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];
$entityType = $meta['entity_type'] ?? null;
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
if ($entityType === null || $entityId === null) {
return;
}
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
$this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment);
}
private function handleSubscriptionActivation(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];
$periodUuid = $meta['period_uuid'] ?? null;
if ($periodUuid === null) {
return;
}
$user = $payment->getUser();
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor !== null) {
$this->subscriptionService->createFromPayment($payment, 'doctor', $doctor->getId(), $periodUuid);
return;
}
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null) {
$this->subscriptionService->createFromPayment($payment, 'clinic', $clinic->getId(), $periodUuid);
}
}
private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response
{
$base = $payment->getFrontendAddress();
+6
View File
@@ -20,6 +20,7 @@ class Payment
public const TYPE_APPOINTMENT = 'appointment';
public const TYPE_SUBSCRIPTION = 'subscription';
public const TYPE_SMS_WALLET = 'sms_wallet';
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -64,6 +65,9 @@ class Payment
#[ORM\Column(name: 'callback_ip', type: 'string', length: 45, nullable: true)]
private ?string $callbackIp = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $metadata = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -96,8 +100,10 @@ class Payment
public function getReferenceId(): ?string { return $this->referenceId; }
public function getFrontendAddress(): ?string { return $this->frontendAddress; }
public function getCallbackIp(): ?string { return $this->callbackIp; }
public function getMetadata(): ?array { return $this->metadata; }
public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; }
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; $this->touch(); return $this; }
public function setGatewayToken(?string $t): self { $this->gatewayToken = $t; $this->touch(); return $this; }
public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; }
public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; }
@@ -4,11 +4,13 @@ namespace App\Secretary\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Subscription\Service\SubscriptionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -19,14 +21,13 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class SecretaryController extends BaseController
{
// TODO: link to subscription plan (Task 15). Basic plan = 1, advanced = 3
private const MAX_SECRETARIES = 1;
public function __construct(
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserRepository $userRepo,
private readonly UserPasswordHasherInterface $hasher,
private readonly SubscriptionService $subscriptionService,
) {}
#[Route('/api/v1/secretary', methods: ['POST'])]
@@ -50,9 +51,12 @@ class SecretaryController extends BaseController
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
// Check plan limit
// Check plan limit (dynamic via SubscriptionService)
$entityType = 'doctor';
$entityId = $doctor->getId();
$limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId);
$activeCount = $this->secretaryRepo->countActiveByDoctor($doctor);
if ($activeCount >= self::MAX_SECRETARIES) {
if ($activeCount >= $limit) {
return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422);
}
+32 -2
View File
@@ -46,6 +46,26 @@ class ErrorCodes
// Secretary
public const ERR_SECRETARY_001 = 'ERR_SECRETARY_001';
// Staff
public const ERR_STAFF_NOT_FOUND = 'ERR_STAFF_NOT_FOUND';
// Subscription
public const ERR_SUBSCRIPTION_REQUIRED = 'ERR_SUBSCRIPTION_REQUIRED';
public const ERR_TRIAL_ALREADY_USED = 'ERR_TRIAL_ALREADY_USED';
public const ERR_TRIAL_DISABLED = 'ERR_TRIAL_DISABLED';
public const ERR_SUBSCRIPTION_NOT_FOUND = 'ERR_SUBSCRIPTION_NOT_FOUND';
// Clinic Services
public const ERR_SERVICE_ITEM_IN_USE = 'ERR_SERVICE_ITEM_IN_USE';
public const ERR_SERVICE_NOT_FOUND = 'ERR_SERVICE_NOT_FOUND';
// Patient
public const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND';
public const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND';
// SMS Wallet
public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT';
// Rate Limit
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
@@ -74,8 +94,18 @@ class ErrorCodes
self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است',
self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمی‌دهد',
self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است',
self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید',
default => 'خطای ناشناخته',
self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید',
self::ERR_STAFF_NOT_FOUND => 'پرسنل یافت نشد',
self::ERR_SUBSCRIPTION_REQUIRED => 'این قابلیت نیاز به پنل Basic یا بالاتر دارد',
self::ERR_TRIAL_ALREADY_USED => 'قبلاً از تریال استفاده کرده‌اید',
self::ERR_TRIAL_DISABLED => 'تریال در حال حاضر غیرفعال است',
self::ERR_SUBSCRIPTION_NOT_FOUND => 'اشتراک یافت نشد',
self::ERR_SERVICE_ITEM_IN_USE => 'این سرویس در پرونده بیمار ثبت شده است',
self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد',
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
default => 'خطای ناشناخته',
};
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
namespace App\Sms\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Config\Repository\SiteConfigRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Payment\Entity\Payment;
use App\Payment\Gateway\MellatGateway;
use App\Payment\Gateway\SepGateway;
use App\Payment\Repository\PaymentRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Entity\SmsSettings;
use App\Sms\Repository\SmsSettingsRepository;
use App\Sms\Repository\SmsWalletRepository;
use App\Sms\Repository\SmsWalletTransactionRepository;
use App\Sms\Service\SmsWalletService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class SmsWalletController extends BaseController
{
public function __construct(
private readonly SmsWalletService $walletService,
private readonly SmsWalletRepository $walletRepo,
private readonly SmsWalletTransactionRepository $txRepo,
private readonly SmsSettingsRepository $settingsRepo,
private readonly PaymentRepository $paymentRepo,
private readonly SiteConfigRepository $configRepo,
private readonly MellatGateway $mellat,
private readonly SepGateway $sep,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly string $appBaseUrl,
) {}
#[Route('/api/v1/sms/wallet/balance', methods: ['GET'])]
public function balance(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$balanceRials = $this->walletService->getBalance($entityType, $entityId);
$smsPriceRials = (int) ($this->configRepo->get('sms_price_rials') ?? 500);
$estimatedSms = $smsPriceRials > 0 ? (int) floor($balanceRials / $smsPriceRials) : 0;
return $this->success([
'balance_rials' => $balanceRials,
'sms_price_rials' => $smsPriceRials,
'estimated_sms_count' => $estimatedSms,
]);
}
#[Route('/api/v1/sms/wallet/charge', methods: ['POST'])]
public function charge(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$gatewayName = trim($data['gateway'] ?? 'mellat');
$amountRials = (int) ($data['amount_rials'] ?? 0);
if ($amountRials <= 0) {
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
}
$gateway = match ($gatewayName) {
'mellat' => $this->mellat,
'sep' => $this->sep,
default => null,
};
if ($gateway === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
}
$frontendAddress = trim($data['frontend_address'] ?? '');
$payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SMS_WALLET, $frontendAddress);
$payment->setMetadata(['entity_type' => $entityType, 'entity_id' => $entityId]);
$this->paymentRepo->save($payment);
$callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId();
$result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl);
if (!$result->success) {
return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage ?? 'درگاه در دسترس نیست', 503);
}
$payment->setGatewayToken($result->token);
$this->paymentRepo->save($payment);
return $this->success([
'payment_uuid' => $payment->getUuid(),
'redirect_url' => $result->redirectUrl,
'order_id' => $payment->getOrderId(),
]);
}
#[Route('/api/v1/sms/wallet/logs', methods: ['GET'])]
public function logs(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(10, (int) $request->query->get('limit', 20)));
$wallet = $this->walletService->getOrCreate($entityType, $entityId);
$txs = $this->txRepo->findByWallet($wallet, $page, $limit);
$total = $this->txRepo->countByWallet($wallet);
return $this->paginated(
array_map(fn($tx) => $tx->toArray(), $txs),
$total,
$page,
$limit
);
}
#[Route('/api/v1/sms/settings', methods: ['GET'])]
public function getSettings(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$settings = $this->settingsRepo->findByEntity($entityType, $entityId);
if ($settings === null) {
return $this->success([
'entity_type' => $entityType,
'entity_id' => $entityId,
'reminder_enabled' => false,
'reminder_hours_before' => 2,
'post_visit_enabled' => false,
'post_visit_text' => null,
]);
}
return $this->success($settings->toArray());
}
#[Route('/api/v1/sms/settings', methods: ['PATCH'])]
public function updateSettings(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$settings = $this->settingsRepo->findByEntity($entityType, $entityId);
if ($settings === null) {
$settings = new SmsSettings($entityType, $entityId);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['reminder_enabled'])) { $settings->setReminderEnabled((bool) $data['reminder_enabled']); }
if (isset($data['reminder_hours_before'])) { $settings->setReminderHoursBefore((int) $data['reminder_hours_before']); }
if (isset($data['post_visit_enabled'])) { $settings->setPostVisitEnabled((bool) $data['post_visit_enabled']); }
if (array_key_exists('post_visit_text', $data)) { $settings->setPostVisitText($data['post_visit_text']); }
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/wallet-report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(10, (int) $request->query->get('limit', 20)));
$total = (int) $this->walletRepo->createQueryBuilder('w')
->select('COUNT(w.id)')
->getQuery()
->getSingleScalarResult();
$wallets = $this->walletRepo->createQueryBuilder('w')
->orderBy('w.balanceRials', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getArrayResult();
return $this->paginated($wallets, $total, $page, $limit);
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Sms\Entity;
use App\Sms\Repository\SmsSettingsRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SmsSettingsRepository::class)]
#[ORM\Table(name: 'sms_settings')]
#[ORM\UniqueConstraint(name: 'uniq_sms_settings_entity', columns: ['entity_type', 'entity_id'])]
class SmsSettings
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'reminder_enabled', type: 'boolean')]
private bool $reminderEnabled = false;
#[ORM\Column(name: 'reminder_hours_before', type: 'smallint')]
private int $reminderHoursBefore = 2;
#[ORM\Column(name: 'post_visit_enabled', type: 'boolean')]
private bool $postVisitEnabled = false;
#[ORM\Column(name: 'post_visit_text', type: 'text', nullable: true)]
private ?string $postVisitText = null;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId)
{
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function isReminderEnabled(): bool { return $this->reminderEnabled; }
public function getReminderHoursBefore(): int { return $this->reminderHoursBefore; }
public function isPostVisitEnabled(): bool { return $this->postVisitEnabled; }
public function getPostVisitText(): ?string { return $this->postVisitText; }
public function setReminderEnabled(bool $v): self { $this->reminderEnabled = $v; $this->updatedAt = time(); return $this; }
public function setReminderHoursBefore(int $v): self { $this->reminderHoursBefore = $v; $this->updatedAt = time(); return $this; }
public function setPostVisitEnabled(bool $v): self { $this->postVisitEnabled = $v; $this->updatedAt = time(); return $this; }
public function setPostVisitText(?string $v): self { $this->postVisitText = $v; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'reminder_enabled' => $this->reminderEnabled,
'reminder_hours_before' => $this->reminderHoursBefore,
'post_visit_enabled' => $this->postVisitEnabled,
'post_visit_text' => $this->postVisitText,
'updated_at' => $this->updatedAt,
];
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Sms\Entity;
use App\Sms\Repository\SmsWalletRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SmsWalletRepository::class)]
#[ORM\Table(name: 'sms_wallets')]
#[ORM\UniqueConstraint(name: 'uniq_sms_wallet_entity', columns: ['entity_type', 'entity_id'])]
class SmsWallet
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'balance_rials', type: 'integer')]
private int $balanceRials = 0;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId)
{
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getBalanceRials(): int { return $this->balanceRials; }
public function credit(int $amount): void
{
$this->balanceRials += $amount;
$this->updatedAt = time();
}
public function debit(int $amount): bool
{
if ($this->balanceRials < $amount) {
return false;
}
$this->balanceRials -= $amount;
$this->updatedAt = time();
return true;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Sms\Entity;
use App\Payment\Entity\Payment;
use App\Sms\Repository\SmsWalletTransactionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SmsWalletTransactionRepository::class)]
#[ORM\Table(name: 'sms_wallet_transactions')]
class SmsWalletTransaction
{
public const TYPE_CREDIT = 'credit';
public const TYPE_DEBIT = 'debit';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: SmsWallet::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private SmsWallet $wallet;
#[ORM\Column(type: 'string', length: 10)]
private string $type;
#[ORM\Column(name: 'amount_rials', type: 'integer')]
private int $amountRials;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $description = null;
#[ORM\ManyToOne(targetEntity: Payment::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Payment $payment = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(SmsWallet $wallet, string $type, int $amountRials, ?string $description = null, ?Payment $payment = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->wallet = $wallet;
$this->type = $type;
$this->amountRials = $amountRials;
$this->description = $description;
$this->payment = $payment;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getType(): string { return $this->type; }
public function getAmountRials(): int { return $this->amountRials; }
public function getDescription(): ?string { return $this->description; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'type' => $this->type,
'amount_rials' => $this->amountRials,
'description' => $this->description,
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Sms\Repository;
use App\Sms\Entity\SmsSettings;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SmsSettingsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SmsSettings::class);
}
public function findByEntity(string $entityType, int $entityId): ?SmsSettings
{
return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]);
}
public function save(SmsSettings $settings): void
{
$this->getEntityManager()->persist($settings);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Sms\Repository;
use App\Sms\Entity\SmsWallet;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SmsWalletRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SmsWallet::class);
}
public function findByEntity(string $entityType, int $entityId): ?SmsWallet
{
return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId]);
}
public function getBalance(string $entityType, int $entityId): int
{
$wallet = $this->findByEntity($entityType, $entityId);
return $wallet?->getBalanceRials() ?? 0;
}
public function save(SmsWallet $wallet): void
{
$this->getEntityManager()->persist($wallet);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Sms\Repository;
use App\Sms\Entity\SmsWallet;
use App\Sms\Entity\SmsWalletTransaction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SmsWalletTransactionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SmsWalletTransaction::class);
}
public function findByWallet(SmsWallet $wallet, int $page = 1, int $limit = 20): array
{
return $this->createQueryBuilder('t')
->where('t.wallet = :wallet')
->setParameter('wallet', $wallet)
->orderBy('t.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countByWallet(SmsWallet $wallet): int
{
return (int) $this->createQueryBuilder('t')
->select('COUNT(t.id)')
->where('t.wallet = :wallet')
->setParameter('wallet', $wallet)
->getQuery()
->getSingleScalarResult();
}
public function save(SmsWalletTransaction $tx): void
{
$this->getEntityManager()->persist($tx);
$this->getEntityManager()->flush();
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Sms\Service;
use App\Payment\Entity\Payment;
use App\Sms\Entity\SmsWallet;
use App\Sms\Entity\SmsWalletTransaction;
use App\Sms\Repository\SmsWalletRepository;
use App\Sms\Repository\SmsWalletTransactionRepository;
class SmsWalletService
{
public function __construct(
private readonly SmsWalletRepository $walletRepo,
private readonly SmsWalletTransactionRepository $txRepo,
) {}
public function getOrCreate(string $entityType, int $entityId): SmsWallet
{
$wallet = $this->walletRepo->findByEntity($entityType, $entityId);
if ($wallet === null) {
$wallet = new SmsWallet($entityType, $entityId);
$this->walletRepo->save($wallet);
}
return $wallet;
}
public function charge(SmsWallet $wallet, int $amountRials, Payment $payment): void
{
$wallet->credit($amountRials);
$this->walletRepo->save($wallet);
$tx = new SmsWalletTransaction(
$wallet,
SmsWalletTransaction::TYPE_CREDIT,
$amountRials,
'شارژ کیف پیامک',
$payment
);
$this->txRepo->save($tx);
}
public function deduct(SmsWallet $wallet, int $amountRials, string $description): bool
{
if (!$wallet->debit($amountRials)) {
return false;
}
$this->walletRepo->save($wallet);
$tx = new SmsWalletTransaction(
$wallet,
SmsWalletTransaction::TYPE_DEBIT,
$amountRials,
$description,
null
);
$this->txRepo->save($tx);
return true;
}
public function getBalance(string $entityType, int $entityId): int
{
return $this->walletRepo->getBalance($entityType, $entityId);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Staff\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Staff\Entity\ClinicStaff;
use App\Staff\Repository\ClinicStaffRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class StaffController extends BaseController
{
public function __construct(
private readonly ClinicStaffRepository $staffRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
) {}
#[Route('/api/v1/staff', methods: ['GET'])]
public function list(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$staff = array_map(
fn(ClinicStaff $s) => $s->toArray(),
$this->staffRepo->findByEntity($entityType, $entityId)
);
return $this->success($staff);
}
#[Route('/api/v1/staff', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$fullName = trim($data['full_name'] ?? '');
if ($fullName === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'full_name الزامی است', 422);
}
$staff = new ClinicStaff($entityType, $entityId, $fullName);
$staff->setPhone($data['phone'] ?? null);
$staff->setJobTitle($data['job_title'] ?? null);
$staff->setAddress($data['address'] ?? null);
$staff->setNationalCode($data['national_code'] ?? null);
$this->staffRepo->save($staff);
return $this->success($staff->toArray(), 201);
}
#[Route('/api/v1/staff/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$staff = $this->staffRepo->findByUuid($uuid);
if ($staff === null) {
return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404);
}
if (!$this->ownsStaff($staff, $user)) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, ErrorCodes::message(ErrorCodes::ERR_FORBIDDEN_001), 403);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['full_name']) && trim($data['full_name']) !== '') {
$staff->setFullName(trim($data['full_name']));
}
if (array_key_exists('phone', $data)) { $staff->setPhone($data['phone']); }
if (array_key_exists('job_title', $data)) { $staff->setJobTitle($data['job_title']); }
if (array_key_exists('address', $data)) { $staff->setAddress($data['address']); }
if (array_key_exists('national_code', $data)){ $staff->setNationalCode($data['national_code']); }
$this->staffRepo->save($staff);
return $this->success($staff->toArray());
}
#[Route('/api/v1/staff/{uuid}/toggle', methods: ['PATCH'])]
public function toggle(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$staff = $this->staffRepo->findByUuid($uuid);
if ($staff === null) {
return $this->error(ErrorCodes::ERR_STAFF_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_STAFF_NOT_FOUND), 404);
}
if (!$this->ownsStaff($staff, $user)) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, ErrorCodes::message(ErrorCodes::ERR_FORBIDDEN_001), 403);
}
$staff->toggleActive();
$this->staffRepo->save($staff);
return $this->success($staff->toArray());
}
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
private function ownsStaff(ClinicStaff $staff, User $user): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
[$entityType, $entityId] = $this->resolveEntity($user);
return $entityId !== null
&& $staff->getEntityType() === $entityType
&& $staff->getEntityId() === $entityId;
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace App\Staff\Entity;
use App\Staff\Repository\ClinicStaffRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
#[ORM\Table(name: 'clinic_staff')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
class ClinicStaff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(name: 'full_name', type: 'string', length: 200)]
private string $fullName;
#[ORM\Column(type: 'string', length: 20, nullable: true)]
private ?string $phone = null;
#[ORM\Column(name: 'job_title', type: 'string', length: 100, nullable: true)]
private ?string $jobTitle = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $address = null;
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, string $fullName)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->fullName = $fullName;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getFullName(): string { return $this->fullName; }
public function getPhone(): ?string { return $this->phone; }
public function getJobTitle(): ?string { return $this->jobTitle; }
public function getAddress(): ?string { return $this->address; }
public function getNationalCode(): ?string { return $this->nationalCode; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setFullName(string $fullName): self { $this->fullName = $fullName; $this->updatedAt = time(); return $this; }
public function setPhone(?string $phone): self { $this->phone = $phone; $this->updatedAt = time(); return $this; }
public function setJobTitle(?string $jobTitle): self { $this->jobTitle = $jobTitle; $this->updatedAt = time(); return $this; }
public function setAddress(?string $address): self { $this->address = $address; $this->updatedAt = time(); return $this; }
public function setNationalCode(?string $code): self { $this->nationalCode = $code; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function toggleActive(): self
{
$this->active = !$this->active;
$this->updatedAt = time();
return $this;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'full_name' => $this->fullName,
'phone' => $this->phone,
'job_title' => $this->jobTitle,
'address' => $this->address,
'national_code' => $this->nationalCode,
'active' => $this->active,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Staff\Repository;
use App\Staff\Entity\ClinicStaff;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClinicStaffRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClinicStaff::class);
}
public function findByUuid(string $uuid): ?ClinicStaff
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByEntity(string $entityType, int $entityId, bool $activeOnly = false): array
{
$qb = $this->createQueryBuilder('s')
->where('s.entityType = :type')
->andWhere('s.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('s.fullName', 'ASC');
if ($activeOnly) {
$qb->andWhere('s.active = true');
}
return $qb->getQuery()->getResult();
}
public function save(ClinicStaff $staff): void
{
$this->getEntityManager()->persist($staff);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,255 @@
<?php
namespace App\Subscription\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Subscription\Entity\SubscriptionPeriod;
use App\Subscription\Entity\SubscriptionPlan;
use App\Subscription\Repository\SubscriptionPlanRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Service\SubscriptionService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class SubscriptionController extends BaseController
{
public function __construct(
private readonly SubscriptionService $subscriptionService,
private readonly SubscriptionPlanRepository $planRepo,
private readonly SubscriptionPeriodRepository $periodRepo,
private readonly ClinicSubscriptionRepository $subscriptionRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly EntityManagerInterface $em,
) {}
// ── Public ──────────────────────────────────────────────────────────────
#[Route('/api/v1/subscription/plans', methods: ['GET'])]
public function plans(): JsonResponse
{
$plans = $this->planRepo->findAllActive();
return $this->success(array_map(
fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true),
$plans
));
}
// ── Authenticated ────────────────────────────────────────────────────────
#[Route('/api/v1/subscription/my', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function my(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$subscription = $this->subscriptionService->getActiveSubscription($entityType, $entityId);
$usedTrial = $this->subscriptionService->hasUsedTrial($entityType, $entityId);
return $this->success([
'subscription' => $subscription?->toArray(),
'used_trial' => $usedTrial,
]);
}
#[Route('/api/v1/subscription/trial', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function trial(#[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
if ($entityId === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
try {
$subscription = $this->subscriptionService->activateTrial($entityType, $entityId);
} catch (AppException $e) {
return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus());
}
return $this->success($subscription->toArray(), 201);
}
// ── Admin ────────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/subscription/plans', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminPlans(): JsonResponse
{
$plans = $this->planRepo->findAllActive();
return $this->paginated(
array_map(fn(SubscriptionPlan $p) => $p->toArray(withPeriods: true), $plans),
count($plans),
1,
100
);
}
#[Route('/api/v1/admin/subscription/plan', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePlan(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '' || !isset($data['level'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'name و level الزامی هستند', 422);
}
$plan = new SubscriptionPlan(
$name,
(int) $data['level'],
(int) ($data['max_secretaries'] ?? 1),
$data['features'] ?? []
);
$this->planRepo->save($plan);
return $this->success($plan->toArray(), 201);
}
#[Route('/api/v1/admin/subscription/plan/{uuid}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function adminUpdatePlan(string $uuid, Request $request): JsonResponse
{
$plan = $this->planRepo->findByUuid($uuid);
if ($plan === null) {
return $this->error(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SUBSCRIPTION_NOT_FOUND), 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['name'])) { $plan->setName($data['name']); }
if (isset($data['level'])) { $plan->setLevel((int) $data['level']); }
if (isset($data['max_secretaries'])) { $plan->setMaxSecretaries((int) $data['max_secretaries']); }
if (isset($data['features'])) { $plan->setFeatures($data['features']); }
if (isset($data['active'])) { $plan->setActive((bool) $data['active']); }
$this->planRepo->save($plan);
return $this->success($plan->toArray());
}
#[Route('/api/v1/admin/subscription/period', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminCreatePeriod(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$planUuid = $data['plan_uuid'] ?? '';
$plan = $this->planRepo->findByUuid($planUuid);
if ($plan === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پنل یافت نشد', 404);
}
if (empty($data['label']) || !isset($data['duration_months'])) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label و duration_months الزامی هستند', 422);
}
$period = new SubscriptionPeriod(
$plan,
$data['label'],
(int) $data['duration_months'],
(int) ($data['price_rials'] ?? 0),
(bool) ($data['is_trial'] ?? false)
);
$period->setSortOrder((int) ($data['sort_order'] ?? 0));
$this->periodRepo->save($period);
return $this->success($period->toArray(), 201);
}
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function adminUpdatePeriod(string $uuid, Request $request): JsonResponse
{
$period = $this->periodRepo->findByUuid($uuid);
if ($period === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['label'])) { $period->setLabel($data['label']); }
if (isset($data['duration_months'])) { $period->setDurationMonths((int) $data['duration_months']); }
if (isset($data['price_rials'])) { $period->setPriceRials((int) $data['price_rials']); }
if (isset($data['active'])) { $period->setActive((bool) $data['active']); }
if (isset($data['sort_order'])) { $period->setSortOrder((int) $data['sort_order']); }
$this->periodRepo->save($period);
return $this->success($period->toArray());
}
#[Route('/api/v1/admin/subscription/period/{uuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function adminDeletePeriod(string $uuid): JsonResponse
{
$period = $this->periodRepo->findByUuid($uuid);
if ($period === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دوره یافت نشد', 404);
}
$period->setActive(false);
$this->periodRepo->save($period);
return $this->success(['message' => 'دوره غیرفعال شد']);
}
#[Route('/api/v1/admin/subscription/report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(10, (int) $request->query->get('limit', 20)));
$total = (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Subscription\Entity\ClinicSubscription s')
->getSingleScalarResult();
$subscriptions = $this->em->createQuery('
SELECT s.uuid, s.entityType, s.entityId, s.isTrial, s.startsAt, s.expiresAt, s.createdAt,
p.name AS plan_name, p.level AS plan_level
FROM App\Subscription\Entity\ClinicSubscription s
JOIN s.plan p
ORDER BY s.id DESC
')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getArrayResult();
return $this->paginated($subscriptions, $total, $page, $limit);
}
// ── Helpers ─────────────────────────────────────────────────────────────
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
return ['unknown', null];
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Subscription\Entity;
use App\Payment\Entity\Payment;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: ClinicSubscriptionRepository::class)]
#[ORM\Table(name: 'clinic_subscriptions')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'expires_at'], name: 'idx_subscription_entity_expires')]
class ClinicSubscription
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\ManyToOne(targetEntity: SubscriptionPlan::class)]
#[ORM\JoinColumn(nullable: false)]
private SubscriptionPlan $plan;
#[ORM\ManyToOne(targetEntity: SubscriptionPeriod::class)]
#[ORM\JoinColumn(nullable: false)]
private SubscriptionPeriod $period;
#[ORM\ManyToOne(targetEntity: Payment::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Payment $payment = null;
#[ORM\Column(name: 'is_trial', type: 'boolean')]
private bool $isTrial = false;
#[ORM\Column(name: 'starts_at', type: 'integer')]
private int $startsAt;
#[ORM\Column(name: 'expires_at', type: 'integer', nullable: true)]
private ?int $expiresAt = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(
string $entityType,
int $entityId,
SubscriptionPlan $plan,
SubscriptionPeriod $period,
bool $isTrial = false,
?int $expiresAt = null,
?Payment $payment = null
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->plan = $plan;
$this->period = $period;
$this->isTrial = $isTrial;
$this->startsAt = time();
$this->expiresAt = $expiresAt;
$this->payment = $payment;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getPlan(): SubscriptionPlan { return $this->plan; }
public function getPeriod(): SubscriptionPeriod { return $this->period; }
public function getPayment(): ?Payment { return $this->payment; }
public function isTrial(): bool { return $this->isTrial; }
public function getStartsAt(): int { return $this->startsAt; }
public function getExpiresAt(): ?int { return $this->expiresAt; }
public function getCreatedAt(): int { return $this->createdAt; }
public function isActive(): bool
{
return $this->expiresAt === null || $this->expiresAt > time();
}
public function getDaysRemaining(): ?int
{
if ($this->expiresAt === null) {
return null;
}
$remaining = $this->expiresAt - time();
return max(0, (int) ceil($remaining / 86400));
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'plan' => $this->plan->toArray(),
'period' => $this->period->toArray(),
'is_trial' => $this->isTrial,
'starts_at' => $this->startsAt,
'expires_at' => $this->expiresAt,
'days_remaining' => $this->getDaysRemaining(),
'is_active' => $this->isActive(),
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Subscription\Entity;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SubscriptionPeriodRepository::class)]
#[ORM\Table(name: 'subscription_periods')]
class SubscriptionPeriod
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: SubscriptionPlan::class, inversedBy: 'periods')]
#[ORM\JoinColumn(nullable: false)]
private SubscriptionPlan $plan;
#[ORM\Column(type: 'string', length: 50)]
private string $label;
#[ORM\Column(name: 'duration_months', type: 'smallint')]
private int $durationMonths;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(name: 'is_trial', type: 'boolean')]
private bool $isTrial = false;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'sort_order', type: 'smallint')]
private int $sortOrder = 0;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(SubscriptionPlan $plan, string $label, int $durationMonths, int $priceRials, bool $isTrial = false)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->plan = $plan;
$this->label = $label;
$this->durationMonths = $durationMonths;
$this->priceRials = $priceRials;
$this->isTrial = $isTrial;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getPlan(): SubscriptionPlan { return $this->plan; }
public function getLabel(): string { return $this->label; }
public function getDurationMonths(): int { return $this->durationMonths; }
public function getPriceRials(): int { return $this->priceRials; }
public function isTrial(): bool { return $this->isTrial; }
public function isActive(): bool { return $this->active; }
public function getSortOrder(): int { return $this->sortOrder; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setLabel(string $label): self { $this->label = $label; $this->updatedAt = time(); return $this; }
public function setDurationMonths(int $v): self { $this->durationMonths = $v; $this->updatedAt = time(); return $this; }
public function setPriceRials(int $v): self { $this->priceRials = $v; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function setSortOrder(int $order): self { $this->sortOrder = $order; $this->updatedAt = time(); return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'plan_uuid' => $this->plan->getUuid(),
'label' => $this->label,
'duration_months'=> $this->durationMonths,
'price_rials' => $this->priceRials,
'is_trial' => $this->isTrial,
'active' => $this->active,
'sort_order' => $this->sortOrder,
];
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Subscription\Entity;
use App\Subscription\Repository\SubscriptionPlanRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: SubscriptionPlanRepository::class)]
#[ORM\Table(name: 'subscription_plans')]
class SubscriptionPlan
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 30, unique: true)]
private string $name;
#[ORM\Column(type: 'smallint')]
private int $level;
#[ORM\Column(name: 'max_secretaries', type: 'smallint')]
private int $maxSecretaries = 1;
#[ORM\Column(type: 'json')]
private array $features = [];
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: SubscriptionPeriod::class, mappedBy: 'plan')]
private Collection $periods;
public function __construct(string $name, int $level, int $maxSecretaries, array $features)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->level = $level;
$this->maxSecretaries = $maxSecretaries;
$this->features = $features;
$this->createdAt = time();
$this->updatedAt = time();
$this->periods = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getName(): string { return $this->name; }
public function getLevel(): int { return $this->level; }
public function getMaxSecretaries(): int { return $this->maxSecretaries; }
public function getFeatures(): array { return $this->features; }
public function isActive(): bool { return $this->active; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
public function getPeriods(): Collection { return $this->periods; }
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
public function setLevel(int $level): self { $this->level = $level; $this->updatedAt = time(); return $this; }
public function setMaxSecretaries(int $v): self { $this->maxSecretaries = $v; $this->updatedAt = time(); return $this; }
public function setFeatures(array $features): self { $this->features = $features; $this->updatedAt = time(); return $this; }
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
public function hasFeature(string $feature): bool
{
return (bool) ($this->features[$feature] ?? false);
}
public function toArray(bool $withPeriods = false): array
{
$data = [
'uuid' => $this->uuid,
'name' => $this->name,
'level' => $this->level,
'max_secretaries' => $this->maxSecretaries,
'features' => $this->features,
'active' => $this->active,
];
if ($withPeriods) {
$data['periods'] = array_map(
fn(SubscriptionPeriod $p) => $p->toArray(),
$this->periods->filter(fn(SubscriptionPeriod $p) => $p->isActive())->toArray()
);
}
return $data;
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Subscription\Repository;
use App\Subscription\Entity\ClinicSubscription;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ClinicSubscriptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClinicSubscription::class);
}
public function findByUuid(string $uuid): ?ClinicSubscription
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findActive(string $entityType, int $entityId): ?ClinicSubscription
{
return $this->createQueryBuilder('s')
->where('s.entityType = :type')
->andWhere('s.entityId = :id')
->andWhere('s.expiresAt IS NULL OR s.expiresAt > :now')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('now', time())
->orderBy('s.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function hasUsedTrial(string $entityType, int $entityId): bool
{
$count = $this->createQueryBuilder('s')
->select('COUNT(s.id)')
->where('s.entityType = :type')
->andWhere('s.entityId = :id')
->andWhere('s.isTrial = true')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->getQuery()
->getSingleScalarResult();
return $count > 0;
}
public function save(ClinicSubscription $subscription): void
{
$this->getEntityManager()->persist($subscription);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Subscription\Repository;
use App\Subscription\Entity\SubscriptionPeriod;
use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SubscriptionPeriodRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SubscriptionPeriod::class);
}
public function findByUuid(string $uuid): ?SubscriptionPeriod
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findTrialPeriodForPlan(SubscriptionPlan $plan): ?SubscriptionPeriod
{
return $this->findOneBy(['plan' => $plan, 'isTrial' => true, 'active' => true]);
}
public function save(SubscriptionPeriod $period): void
{
$this->getEntityManager()->persist($period);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Subscription\Repository;
use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SubscriptionPlanRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SubscriptionPlan::class);
}
public function findByUuid(string $uuid): ?SubscriptionPlan
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByName(string $name): ?SubscriptionPlan
{
return $this->findOneBy(['name' => $name, 'active' => true]);
}
public function findAllActive(): array
{
return $this->createQueryBuilder('p')
->where('p.active = true')
->orderBy('p.level', 'ASC')
->getQuery()
->getResult();
}
public function save(SubscriptionPlan $plan): void
{
$this->getEntityManager()->persist($plan);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Subscription\Service;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Entity\Payment;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Subscription\Entity\ClinicSubscription;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\SubscriptionPlanRepository;
class SubscriptionService
{
public function __construct(
private readonly ClinicSubscriptionRepository $subscriptionRepo,
private readonly SubscriptionPlanRepository $planRepo,
private readonly SubscriptionPeriodRepository $periodRepo,
private readonly SiteConfigRepository $configRepo,
) {}
public function getActiveSubscription(string $entityType, int $entityId): ?ClinicSubscription
{
return $this->subscriptionRepo->findActive($entityType, $entityId);
}
public function hasFeature(string $entityType, int $entityId, string $feature): bool
{
$subscription = $this->getActiveSubscription($entityType, $entityId);
if ($subscription === null) {
return false;
}
return $subscription->getPlan()->hasFeature($feature);
}
public function getSecretaryLimit(string $entityType, int $entityId): int
{
$subscription = $this->getActiveSubscription($entityType, $entityId);
if ($subscription === null) {
return 1;
}
return $subscription->getPlan()->getMaxSecretaries();
}
public function hasUsedTrial(string $entityType, int $entityId): bool
{
return $this->subscriptionRepo->hasUsedTrial($entityType, $entityId);
}
public function activateTrial(string $entityType, int $entityId): ClinicSubscription
{
if ($this->hasUsedTrial($entityType, $entityId)) {
throw new AppException(ErrorCodes::ERR_TRIAL_ALREADY_USED, null, 422);
}
$trialEnabled = $this->configRepo->get('trial_enabled');
if ($trialEnabled === '0') {
throw new AppException(ErrorCodes::ERR_TRIAL_DISABLED, null, 422);
}
$basicPlan = $this->planRepo->findByName('basic');
if ($basicPlan === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
}
$trialPeriod = $this->periodRepo->findTrialPeriodForPlan($basicPlan);
if ($trialPeriod === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500);
}
$expiresAt = $this->calculateExpiresAt(null, $trialPeriod->getDurationMonths());
$subscription = new ClinicSubscription(
$entityType,
$entityId,
$basicPlan,
$trialPeriod,
true,
$expiresAt,
null
);
$this->subscriptionRepo->save($subscription);
return $subscription;
}
public function createFromPayment(Payment $payment, string $entityType, int $entityId, string $periodUuid): ClinicSubscription
{
$period = $this->periodRepo->findByUuid($periodUuid);
if ($period === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 404);
}
$currentSub = $this->getActiveSubscription($entityType, $entityId);
$currentExpires = $currentSub?->getExpiresAt();
$expiresAt = $this->calculateExpiresAt($currentExpires, $period->getDurationMonths());
$subscription = new ClinicSubscription(
$entityType,
$entityId,
$period->getPlan(),
$period,
false,
$expiresAt,
$payment
);
$this->subscriptionRepo->save($subscription);
return $subscription;
}
public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int
{
$base = max($currentExpiresAt ?? 0, time());
return $base + $durationMonths * 30 * 86400;
}
}