diff --git a/config/services.yaml b/config/services.yaml index 3a7ce0d0..a8cf5395 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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)%' diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md new file mode 100644 index 00000000..e92e0d27 --- /dev/null +++ b/docs/api/clinic-services.md @@ -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 | سرویس در پرونده بیمار استفاده شده | diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index 092d63dd..30c314d7 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -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` diff --git a/docs/api/patient.md b/docs/api/patient.md new file mode 100644 index 00000000..bb5ebdee --- /dev/null +++ b/docs/api/patient.md @@ -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. diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 9bead74f..274f2e70 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -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` (پرداخت) ارتقاء داد. diff --git a/docs/api/sms.md b/docs/api/sms.md index 1cad529f..8070e6df 100644 --- a/docs/api/sms.md +++ b/docs/api/sms.md @@ -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) diff --git a/docs/api/staff.md b/docs/api/staff.md new file mode 100644 index 00000000..87480f82 --- /dev/null +++ b/docs/api/staff.md @@ -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 | دسترسی ندارید | diff --git a/docs/api/subscription.md b/docs/api/subscription.md new file mode 100644 index 00000000..95b3ca5f --- /dev/null +++ b/docs/api/subscription.md @@ -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 | پنل یافت نشد | diff --git a/migrations/Version20260614181134.php b/migrations/Version20260614181134.php new file mode 100644 index 00000000..15998539 --- /dev/null +++ b/migrations/Version20260614181134.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/migrations/Version20260614181629.php b/migrations/Version20260614181629.php new file mode 100644 index 00000000..2dc7450e --- /dev/null +++ b/migrations/Version20260614181629.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/migrations/Version20260614181657.php b/migrations/Version20260614181657.php new file mode 100644 index 00000000..62b30393 --- /dev/null +++ b/migrations/Version20260614181657.php @@ -0,0 +1,84 @@ +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')"); + } +} diff --git a/migrations/Version20260614182549.php b/migrations/Version20260614182549.php new file mode 100644 index 00000000..9c60ae0c --- /dev/null +++ b/migrations/Version20260614182549.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/migrations/Version20260614182950.php b/migrations/Version20260614182950.php new file mode 100644 index 00000000..a44d9186 --- /dev/null +++ b/migrations/Version20260614182950.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/migrations/Version20260614183527.php b/migrations/Version20260614183527.php new file mode 100644 index 00000000..5644ea8f --- /dev/null +++ b/migrations/Version20260614183527.php @@ -0,0 +1,47 @@ +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'); + } +} diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 68181694..a36115ca 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -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], ]); } } diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index ab1d19dd..58bbe8ce 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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()]); } } diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php new file mode 100644 index 00000000..d0a7436c --- /dev/null +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -0,0 +1,241 @@ +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; + } +} diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php new file mode 100644 index 00000000..02ff5e6c --- /dev/null +++ b/src/ClinicService/Entity/ServiceItem.php @@ -0,0 +1,84 @@ +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, + ]; + } +} diff --git a/src/ClinicService/Entity/ServiceSection.php b/src/ClinicService/Entity/ServiceSection.php new file mode 100644 index 00000000..e2c5b12f --- /dev/null +++ b/src/ClinicService/Entity/ServiceSection.php @@ -0,0 +1,80 @@ +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, + ]; + } +} diff --git a/src/ClinicService/Repository/ServiceItemRepository.php b/src/ClinicService/Repository/ServiceItemRepository.php new file mode 100644 index 00000000..bdd86632 --- /dev/null +++ b/src/ClinicService/Repository/ServiceItemRepository.php @@ -0,0 +1,43 @@ +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(); + } +} diff --git a/src/ClinicService/Repository/ServiceSectionRepository.php b/src/ClinicService/Repository/ServiceSectionRepository.php new file mode 100644 index 00000000..48428f2c --- /dev/null +++ b/src/ClinicService/Repository/ServiceSectionRepository.php @@ -0,0 +1,44 @@ +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(); + } +} diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index 21147e02..e11ae1b1 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -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, ]); diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php new file mode 100644 index 00000000..8b714db3 --- /dev/null +++ b/src/Patient/Controller/PatientController.php @@ -0,0 +1,193 @@ +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; + } +} diff --git a/src/Patient/Entity/PatientRecord.php b/src/Patient/Entity/PatientRecord.php new file mode 100644 index 00000000..e8a3f596 --- /dev/null +++ b/src/Patient/Entity/PatientRecord.php @@ -0,0 +1,81 @@ +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, + ]; + } +} diff --git a/src/Patient/Entity/PatientSession.php b/src/Patient/Entity/PatientSession.php new file mode 100644 index 00000000..1a00aab8 --- /dev/null +++ b/src/Patient/Entity/PatientSession.php @@ -0,0 +1,121 @@ +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, + ]; + } +} diff --git a/src/Patient/Entity/SessionService.php b/src/Patient/Entity/SessionService.php new file mode 100644 index 00000000..8d0ec45f --- /dev/null +++ b/src/Patient/Entity/SessionService.php @@ -0,0 +1,71 @@ +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, + ]; + } +} diff --git a/src/Patient/Repository/PatientRecordRepository.php b/src/Patient/Repository/PatientRecordRepository.php new file mode 100644 index 00000000..3d1a6170 --- /dev/null +++ b/src/Patient/Repository/PatientRecordRepository.php @@ -0,0 +1,90 @@ +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(); + } +} diff --git a/src/Patient/Repository/PatientSessionRepository.php b/src/Patient/Repository/PatientSessionRepository.php new file mode 100644 index 00000000..27471b6d --- /dev/null +++ b/src/Patient/Repository/PatientSessionRepository.php @@ -0,0 +1,67 @@ +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(); + } +} diff --git a/src/Patient/Repository/SessionServiceRepository.php b/src/Patient/Repository/SessionServiceRepository.php new file mode 100644 index 00000000..69aaf0d4 --- /dev/null +++ b/src/Patient/Repository/SessionServiceRepository.php @@ -0,0 +1,21 @@ +getEntityManager()->persist($service); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php new file mode 100644 index 00000000..f38d8d9d --- /dev/null +++ b/src/Patient/Service/PatientService.php @@ -0,0 +1,117 @@ + (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; + } +} diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 46cdd7d1..02907ed6 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -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(); diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php index 3b417a9d..4fe88913 100644 --- a/src/Payment/Entity/Payment.php +++ b/src/Payment/Entity/Payment.php @@ -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; } diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php index 4df3e338..e20c75b5 100644 --- a/src/Secretary/Controller/SecretaryController.php +++ b/src/Secretary/Controller/SecretaryController.php @@ -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); } diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index c639c987..094674b8 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -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 => 'خطای ناشناخته', }; } } diff --git a/src/Sms/Controller/SmsWalletController.php b/src/Sms/Controller/SmsWalletController.php new file mode 100644 index 00000000..dab80db9 --- /dev/null +++ b/src/Sms/Controller/SmsWalletController.php @@ -0,0 +1,218 @@ +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]; + } +} diff --git a/src/Sms/Entity/SmsSettings.php b/src/Sms/Entity/SmsSettings.php new file mode 100644 index 00000000..0e1c13d8 --- /dev/null +++ b/src/Sms/Entity/SmsSettings.php @@ -0,0 +1,71 @@ +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, + ]; + } +} diff --git a/src/Sms/Entity/SmsWallet.php b/src/Sms/Entity/SmsWallet.php new file mode 100644 index 00000000..349b35a5 --- /dev/null +++ b/src/Sms/Entity/SmsWallet.php @@ -0,0 +1,61 @@ +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; + } +} diff --git a/src/Sms/Entity/SmsWalletTransaction.php b/src/Sms/Entity/SmsWalletTransaction.php new file mode 100644 index 00000000..622baf78 --- /dev/null +++ b/src/Sms/Entity/SmsWalletTransaction.php @@ -0,0 +1,73 @@ +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, + ]; + } +} diff --git a/src/Sms/Repository/SmsSettingsRepository.php b/src/Sms/Repository/SmsSettingsRepository.php new file mode 100644 index 00000000..11669bc5 --- /dev/null +++ b/src/Sms/Repository/SmsSettingsRepository.php @@ -0,0 +1,26 @@ +findOneBy(['entityType' => $entityType, 'entityId' => $entityId]); + } + + public function save(SmsSettings $settings): void + { + $this->getEntityManager()->persist($settings); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Sms/Repository/SmsWalletRepository.php b/src/Sms/Repository/SmsWalletRepository.php new file mode 100644 index 00000000..d3260c9f --- /dev/null +++ b/src/Sms/Repository/SmsWalletRepository.php @@ -0,0 +1,32 @@ +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(); + } +} diff --git a/src/Sms/Repository/SmsWalletTransactionRepository.php b/src/Sms/Repository/SmsWalletTransactionRepository.php new file mode 100644 index 00000000..6facd924 --- /dev/null +++ b/src/Sms/Repository/SmsWalletTransactionRepository.php @@ -0,0 +1,44 @@ +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(); + } +} diff --git a/src/Sms/Service/SmsWalletService.php b/src/Sms/Service/SmsWalletService.php new file mode 100644 index 00000000..e8599a25 --- /dev/null +++ b/src/Sms/Service/SmsWalletService.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/src/Staff/Controller/StaffController.php b/src/Staff/Controller/StaffController.php new file mode 100644 index 00000000..1c66c7ac --- /dev/null +++ b/src/Staff/Controller/StaffController.php @@ -0,0 +1,140 @@ +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; + } +} diff --git a/src/Staff/Entity/ClinicStaff.php b/src/Staff/Entity/ClinicStaff.php new file mode 100644 index 00000000..7021b1c5 --- /dev/null +++ b/src/Staff/Entity/ClinicStaff.php @@ -0,0 +1,105 @@ +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, + ]; + } +} diff --git a/src/Staff/Repository/ClinicStaffRepository.php b/src/Staff/Repository/ClinicStaffRepository.php new file mode 100644 index 00000000..77795919 --- /dev/null +++ b/src/Staff/Repository/ClinicStaffRepository.php @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php new file mode 100644 index 00000000..e68f76d6 --- /dev/null +++ b/src/Subscription/Controller/SubscriptionController.php @@ -0,0 +1,255 @@ +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]; + } +} diff --git a/src/Subscription/Entity/ClinicSubscription.php b/src/Subscription/Entity/ClinicSubscription.php new file mode 100644 index 00000000..9097727d --- /dev/null +++ b/src/Subscription/Entity/ClinicSubscription.php @@ -0,0 +1,114 @@ +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, + ]; + } +} diff --git a/src/Subscription/Entity/SubscriptionPeriod.php b/src/Subscription/Entity/SubscriptionPeriod.php new file mode 100644 index 00000000..1b4056af --- /dev/null +++ b/src/Subscription/Entity/SubscriptionPeriod.php @@ -0,0 +1,92 @@ +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, + ]; + } +} diff --git a/src/Subscription/Entity/SubscriptionPlan.php b/src/Subscription/Entity/SubscriptionPlan.php new file mode 100644 index 00000000..6a465c68 --- /dev/null +++ b/src/Subscription/Entity/SubscriptionPlan.php @@ -0,0 +1,101 @@ +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; + } +} diff --git a/src/Subscription/Repository/ClinicSubscriptionRepository.php b/src/Subscription/Repository/ClinicSubscriptionRepository.php new file mode 100644 index 00000000..5dda885e --- /dev/null +++ b/src/Subscription/Repository/ClinicSubscriptionRepository.php @@ -0,0 +1,56 @@ +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(); + } +} diff --git a/src/Subscription/Repository/SubscriptionPeriodRepository.php b/src/Subscription/Repository/SubscriptionPeriodRepository.php new file mode 100644 index 00000000..20dd6b45 --- /dev/null +++ b/src/Subscription/Repository/SubscriptionPeriodRepository.php @@ -0,0 +1,32 @@ +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(); + } +} diff --git a/src/Subscription/Repository/SubscriptionPlanRepository.php b/src/Subscription/Repository/SubscriptionPlanRepository.php new file mode 100644 index 00000000..70bf86ad --- /dev/null +++ b/src/Subscription/Repository/SubscriptionPlanRepository.php @@ -0,0 +1,40 @@ +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(); + } +} diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php new file mode 100644 index 00000000..792d98c3 --- /dev/null +++ b/src/Subscription/Service/SubscriptionService.php @@ -0,0 +1,123 @@ +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; + } +}