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:
@@ -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)%'
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 (10–50) |
|
||||
| `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.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 | دسترسی ندارید |
|
||||
@@ -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 | پنل یافت نشد |
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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')");
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user