From dcd631f503fec6ae48636390406f4482b60f3dc7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 14 Jun 2026 21:38:48 +0330 Subject: [PATCH] feat: implement Phase 2 backend tasks including staff management, subscription tiers, secretary completion, clinic services, SMS panel, patient records, and smart dashboard --- .claude/prompt/implement-phase2-backend.md | 580 +++++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 .claude/prompt/implement-phase2-backend.md diff --git a/.claude/prompt/implement-phase2-backend.md b/.claude/prompt/implement-phase2-backend.md new file mode 100644 index 00000000..38076912 --- /dev/null +++ b/.claude/prompt/implement-phase2-backend.md @@ -0,0 +1,580 @@ +# پیاده‌سازی فاز ۲ — Backend تسک‌به‌تسک + +## زمینه + +تسک‌های فاز ۲ در `docs/phase2_taskes/` طراحی شده‌اند (task-10 تا task-16). +این پرامپت فقط **Backend** را پیاده‌سازی می‌کند — Entity، Migration، Repository، Service، Controller — و +بعد از هر تسک، فایل داک مربوطه در `docs/api/` ایجاد یا به‌روز می‌کند. + +## ترتیب اجباری تسک‌ها (وابستگی) + +``` +task-10 (Staff) ← پیش‌نیاز task-13 و task-15 + ↓ +task-11 (Subscription) ← پیش‌نیاز gate check در task-12، 13، 15 + ↓ +task-12 (Secretary) ← فقط SecretaryController تغییر می‌کند + ↓ +task-13 (Services) ← نیاز به Staff و Subscription + ↓ +task-14 (SMS Panel) ← مستقل، بعد از task-11 (نه الزامی) + ↓ +task-15 (Patient) ← نیاز به Staff + Subscription + Services + ↓ +task-16 (Dashboard) ← آخر — فقط query های موجود تغییر می‌کنند +``` + +## قوانین عمومی پروژه + +### Backend (Symfony 7) +- همه controller‌ها از `App\Shared\Controller\BaseController` ارث می‌برند +- پاسخ‌ها **فقط** با این متدها: + - `$this->success($data, $httpStatus)` — single resource + - `$this->paginated($items, $total, $page, $limit)` — لیست با صفحه‌بندی + - `$this->error($code, $message, $httpStatus)` — خطا + - `$this->validationError($violations)` — خطای validation (422) +- تاریخ‌ها **همیشه** Unix timestamp (`int`) — نه DateTime، نه DateTimeImmutable +- بعد از هر تغییر Entity: `doctrine:migrations:diff` و `doctrine:migrations:migrate` +- Error code های جدید باید در `src/Shared/Constant/ErrorCodes.php` اضافه شوند + +### ساختار Entity (الگو) +```php +#[ORM\Entity(repositoryClass: XxxRepository::class)] +#[ORM\Table(name: 'table_name')] +class XxxEntity +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 36, unique: true)] + private string $uuid; + + // polymorphic: + #[ORM\Column(type: 'string', length: 10)] + private string $entityType; // 'doctor' | 'clinic' + + #[ORM\Column(type: 'integer')] + private int $entityId; + + #[ORM\Column(type: 'integer')] + private int $createdAt; + + #[ORM\Column(type: 'integer')] + private int $updatedAt; + + public function __construct() + { + $this->uuid = (string) \Symfony\Component\Uid\Uuid::v4(); + $this->createdAt = time(); + $this->updatedAt = time(); + } +} +``` + +### توجه مهم — BaseController::success() double-nested +```php +// اشتباه — double-nested data: +return $this->success(['data' => $item->toArray()]); +// نتیجه: { data: { data: {...} } } — frontend باید data?.data?.data بزند + +// درست: +return $this->success($item->toArray()); +// نتیجه: { data: {...} } + +// برای لیست paginated: array مستقیم (نه ['data' => ...]) +return $this->paginated($items, $total, $page, $limit); +``` + +### SecretaryController — وضعیت واقعی فعلی +```php +// src/Secretary/Controller/SecretaryController.php (line 19) +private const MAX_SECRETARIES = 1; +// TODO موجود: link to subscription plan + +// متد create() در line 32 — دارد MAX_SECRETARIES را check می‌کند +// اما هنوز SubscriptionService inject نشده +// ERR_SECRETARY_001 موجود در ErrorCodes.php +``` + +### Payment entity — وضعیت واقعی +```php +// src/Payment/Entity/Payment.php +public const TYPE_APPOINTMENT = 'appointment'; +public const TYPE_SUBSCRIPTION = 'subscription'; +// TYPE_SMS_WALLET هنوز وجود ندارد — باید اضافه شود +``` + +### SmsService — signature واقعی +```php +// src/Sms/Service/SmsService.php +public function dispatchAsync(string $mobile, string $message, string $provider = 'kavenegar', ...): void +public function sendNow(SendSmsMessage $msg): bool +// entityType/entityId پارامتر ندارد — wallet deduct باید در caller انجام شود +``` + +### AppointmentController::updateStatus — وضعیت واقعی +```php +// src/Appointment/Controller/AppointmentController.php +// بعد از $appointment->setStatus($newStatus) و flush: +// باید PatientService::autoCreateOnAppointmentConfirm() اضافه شود +``` + +--- + +## تسک‌ها + +--- + +### TASK-10: مدیریت پرسنل (Staff) + +**مرجع:** `docs/phase2_taskes/task-10-staff/` + +#### فایل‌های جدید: +``` +src/Staff/Entity/ClinicStaff.php +src/Staff/Repository/ClinicStaffRepository.php +src/Staff/Service/StaffService.php +src/Staff/Controller/StaffController.php +migrations/Version_YYYYMMDD_staff.php +``` + +#### Entity ClinicStaff: +فیلدها: `id`, `uuid`, `entityType` (VARCHAR 10), `entityId` (INT), `fullName` (VARCHAR 200, required), +`phone` (nullable), `jobTitle` (nullable), `address` (TEXT nullable), `nationalCode` (CHAR 10 nullable), +`active` (bool=true), `createdAt` (int), `updatedAt` (int). + +Index: `(entity_type, entity_id, active)` + +#### Controller endpoints: +``` +GET /api/v1/staff → لیست (entity از JWT) +POST /api/v1/staff → ایجاد +PATCH /api/v1/staff/{uuid} → ویرایش +PATCH /api/v1/staff/{uuid}/toggle → toggle active +``` + +#### نکات: +- entity_type و entity_id از JWT token claim (`db_key` و `db_uuid` → resolve به id) +- toggle: فقط `active = !active` — هیچ حذفی نیست +- ownership check: staff.entity_type/entityId == JWT claim +- **بعد از پیاده‌سازی:** فایل `docs/api/staff.md` بساز + +--- + +### TASK-11: پنل اشتراکی (Subscription Tiers) + +**مرجع:** `docs/phase2_taskes/task-11-subscription/` + +#### فایل‌های جدید: +``` +src/Subscription/Entity/SubscriptionPlan.php +src/Subscription/Entity/SubscriptionPeriod.php +src/Subscription/Entity/ClinicSubscription.php +src/Subscription/Repository/SubscriptionPlanRepository.php +src/Subscription/Repository/SubscriptionPeriodRepository.php +src/Subscription/Repository/ClinicSubscriptionRepository.php +src/Subscription/Service/SubscriptionService.php +src/Subscription/Controller/SubscriptionController.php +migrations/Version_YYYYMMDD_subscription.php +``` + +#### SubscriptionPlan: `id`, `uuid`, `name` (VARCHAR 30: free/basic/professional), `level` (TINYINT), +`maxSecretaries` (TINYINT), `features` (JSON: `{"patient_records":bool,"services":bool,"sms_panel":bool}`), +`active` (bool), `createdAt`, `updatedAt`. + +#### SubscriptionPeriod: `id`, `uuid`, `plan` (FK→SubscriptionPlan), `label` (VARCHAR 50), +`durationMonths` (TINYINT), `priceRials` (INT, 0 برای تریال), `isTrial` (bool=false), +`active` (bool=true), `sortOrder` (TINYINT=0), `createdAt`, `updatedAt`. + +#### ClinicSubscription: `id`, `uuid`, `entityType`, `entityId`, `plan` (FK), +`period` (FK), `payment` (FK nullable → Payment), `isTrial` (bool), `startsAt` (int), +`expiresAt` (int nullable — NULL = بی‌نهایت)، `createdAt`. + +Index روی `(entity_type, entity_id, expires_at)`. + +#### SubscriptionService — متدهای الزامی: +```php +public function getActiveSubscription(string $entityType, int $entityId): ?ClinicSubscription +// query: WHERE entity_type=? AND entity_id=? AND (expires_at IS NULL OR expires_at > :now) ORDER BY id DESC LIMIT 1 + +public function hasFeature(string $entityType, int $entityId, string $feature): bool +// false اگر اشتراک نداشت یا plan->features[$feature] = false + +public function getSecretaryLimit(string $entityType, int $entityId): int +// اشتراک فعال → plan->maxSecretaries — اگر اشتراک نداشت: 1 (Free default) + +public function hasUsedTrial(string $entityType, int $entityId): bool + +public function activateTrial(string $entityType, int $entityId): ClinicSubscription +// بررسی hasUsedTrial → throw اگر قبلاً استفاده شده +// بررسی SiteConfig['trial_enabled'] != '0' +// plan Basic پیدا کن → period is_trial=true پیدا کن +// ClinicSubscription با payment=null بساز + +public function calculateExpiresAt(?int $currentExpiresAt, int $durationMonths): int +// base = max($currentExpiresAt ?? 0, time()) +// return base + durationMonths * 30 * 86400 +``` + +#### SubscriptionController endpoints: +``` +GET /api/v1/subscription/plans → public — لیست با nested periods +GET /api/v1/subscription/my → JWT — اشتراک فعال + used_trial + days_remaining +POST /api/v1/subscription/trial → JWT — activateTrial() +GET /api/v1/admin/subscription/plans → ROLE_ADMIN +POST /api/v1/admin/subscription/plan → ROLE_ADMIN +PATCH /api/v1/admin/subscription/plan/{uuid} → ROLE_ADMIN +POST /api/v1/admin/subscription/period → ROLE_ADMIN +PATCH /api/v1/admin/subscription/period/{uuid} → ROLE_ADMIN +DELETE /api/v1/admin/subscription/period/{uuid} → ROLE_ADMIN (active=false) +GET /api/v1/admin/subscription/report → ROLE_ADMIN +``` + +#### تغییر PaymentController: +بعد از تأیید موفق callback نوع `TYPE_SUBSCRIPTION`: +```php +// payment->type === Payment::TYPE_SUBSCRIPTION: +// 1. از meta یا period_uuid پرداخت، SubscriptionPeriod پیدا کن +// 2. subscriptionService->calculateExpiresAt(currentSub?->expiresAt, period->durationMonths) +// 3. ClinicSubscription جدید بساز و persist کن +``` + +#### Seed data در migration: +سه plan (free, basic, professional) + دوره‌های نمونه برای basic. + +#### Error codes جدید در ErrorCodes.php: +```php +const ERR_SUBSCRIPTION_REQUIRED = 'ERR_SUBSCRIPTION_REQUIRED'; // 'این قابلیت نیاز به پنل Basic یا بالاتر دارد' +const ERR_TRIAL_ALREADY_USED = 'ERR_TRIAL_ALREADY_USED'; // 'قبلاً از تریال استفاده کرده‌اید' +const ERR_TRIAL_DISABLED = 'ERR_TRIAL_DISABLED'; // 'تریال در حال حاضر غیرفعال است' +``` + +**بعد از پیاده‌سازی:** فایل `docs/api/subscription.md` بساز. + +--- + +### TASK-12: تکمیل منشی — محدودیت پنل + +**مرجع:** `docs/phase2_taskes/task-12-secretary-completion/` + +#### فایل‌های تغییرکننده (هیچ فایل جدیدی نیست): +- `src/Secretary/Controller/SecretaryController.php` +- `src/Secretary/Repository/DoctorSecretaryRepository.php` + +#### تغییر SecretaryController::create(): +```php +// وضعیت فعلی (line 19): private const MAX_SECRETARIES = 1; +// باید حذف شود و جایش با SubscriptionService جایگزین شود + +// inject SubscriptionService در constructor + +// در create() — جایگزین check فعلی: +// $limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId); +// $activeCount = $this->secretaryRepo->countActiveByDoctor($doctor); // متد موجود +// if ($activeCount >= $limit) → error ERR_SECRETARY_001 +``` + +#### نکته مهم — متد موجود: +`countActiveByDoctor(Doctor $doctor)` در `DoctorSecretaryRepository` **از قبل وجود دارد** — نیازی به ایجاد نیست. + +#### Error code موجود: +`ERR_SECRETARY_001` در ErrorCodes.php موجود است — نیازی به اضافه کردن نیست. + +**بعد از پیاده‌سازی:** بخش «محدودیت پنل» را به `docs/api/secretary.md` اضافه کن. + +--- + +### TASK-13: سرویس‌های کلینیک (Clinic Services) + +**مرجع:** `docs/phase2_taskes/task-13-clinic-services/` + +#### فایل‌های جدید: +``` +src/ClinicService/Entity/ServiceSection.php +src/ClinicService/Entity/ServiceItem.php +src/ClinicService/Repository/ServiceSectionRepository.php +src/ClinicService/Repository/ServiceItemRepository.php +src/ClinicService/Controller/ClinicServiceController.php +migrations/Version_YYYYMMDD_clinic_service.php +``` + +#### ServiceSection: `id`, `uuid`, `entityType`, `entityId`, `name` (VARCHAR 200), `active` (bool=true), `createdAt`, `updatedAt`. +Index: `(entity_type, entity_id)`. + +#### ServiceItem: `id`, `uuid`, `section` (FK→ServiceSection ON DELETE CASCADE), +`staff` (FK→ClinicStaff nullable ON DELETE SET NULL), `name` (VARCHAR 200), `priceRials` (INT=0), +`active` (bool=true), `createdAt`, `updatedAt`. + +#### Controller endpoints: +``` +GET /api/v1/service-sections → Basic+ gate +POST /api/v1/service-section → Basic+ gate +PATCH /api/v1/service-section/{uuid} → Basic+ gate +DELETE /api/v1/service-section/{uuid} → Basic+ gate +GET /api/v1/service-items/{sectionUuid} +POST /api/v1/service-item → Basic+ gate +PATCH /api/v1/service-item/{uuid} +DELETE /api/v1/service-item/{uuid} +``` + +#### gate check helper: +```php +private function assertServicesGate(): void +{ + [$type, $id] = $this->resolveEntityFromJwt(); + if (!$this->subscriptionService->hasFeature($type, $id, 'services')) { + throw new \App\Shared\Exception\AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403); + } +} +``` + +#### حذف ServiceItem با استفاده در پرونده: +```php +// session_services.service_item_id FK ON DELETE RESTRICT +// اگر در session_services باشد → DBAL ForeignKeyConstraintViolationException +try { + $this->em->remove($item); $this->em->flush(); +} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException) { + return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, 'این سرویس در پرونده بیمار ثبت شده است', 409); +} +``` + +#### Error codes جدید: +```php +const ERR_SERVICE_ITEM_IN_USE = 'ERR_SERVICE_ITEM_IN_USE'; // 'این سرویس در پرونده بیمار ثبت شده است' +``` + +**بعد از پیاده‌سازی:** فایل `docs/api/clinic-services.md` بساز. + +--- + +### TASK-14: پنل پیامکی (SMS Panel) + +**مرجع:** `docs/phase2_taskes/task-14-sms-panel/` + +#### فایل‌های جدید: +``` +src/Sms/Entity/SmsWallet.php +src/Sms/Entity/SmsWalletTransaction.php +src/Sms/Entity/SmsSettings.php +src/Sms/Repository/SmsWalletRepository.php +src/Sms/Repository/SmsWalletTransactionRepository.php +src/Sms/Repository/SmsSettingsRepository.php +src/Sms/Service/SmsWalletService.php +src/Sms/Controller/SmsWalletController.php +migrations/Version_YYYYMMDD_sms_panel.php +``` + +#### فایل‌های تغییرکننده: +- `src/Payment/Entity/Payment.php` — اضافه کردن `const TYPE_SMS_WALLET = 'sms_wallet'` +- `src/Payment/Controller/PaymentController.php` — callback برای type=sms_wallet + +#### SmsWallet: `id`, `entityType`, `entityId`, `balanceRials` (INT=0), `createdAt`, `updatedAt`. +UNIQUE INDEX روی `(entity_type, entity_id)`. + +#### SmsWalletTransaction: `id`, `uuid`, `wallet` (FK→SmsWallet ON DELETE CASCADE), +`type` (VARCHAR 10: credit/debit), `amountRials` (INT), `description` (nullable), +`payment` (FK→Payment nullable ON DELETE SET NULL), `createdAt`. + +#### SmsSettings: `id`, `entityType`, `entityId`, `reminderEnabled` (bool=false), +`reminderHoursBefore` (TINYINT=2), `postVisitEnabled` (bool=false), `postVisitText` (TEXT nullable), +`updatedAt`. UNIQUE INDEX روی `(entity_type, entity_id)`. + +#### SmsWalletService: +```php +public function getOrCreate(string $entityType, int $entityId): SmsWallet +public function charge(SmsWallet $wallet, int $amountRials, Payment $payment): void // ثبت credit tx +public function deduct(SmsWallet $wallet, int $amountRials, string $description): bool // ثبت debit tx +public function getBalance(string $entityType, int $entityId): int +``` + +#### SmsWalletController endpoints: +``` +GET /api/v1/sms/wallet/balance → موجودی + sms_price_rials از SiteConfig + estimated_sms_count +POST /api/v1/sms/wallet/charge → { gateway, amount_rials } → Payment TYPE_SMS_WALLET → redirect +GET /api/v1/sms/wallet/logs → paginated SmsWalletTransactions +GET /api/v1/sms/settings → SmsSettings (یا مقادیر پیش‌فرض) +PATCH /api/v1/sms/settings → UPSERT SmsSettings +GET /api/v1/admin/sms/wallet-report → ROLE_ADMIN +``` + +#### تغییر PaymentController callback برای sms_wallet: +```php +if ($payment->getType() === Payment::TYPE_SMS_WALLET) { + $wallet = $this->smsWalletService->getOrCreate($entityType, $entityId); + $this->smsWalletService->charge($wallet, $payment->getAmountRials(), $payment); +} +``` + +#### SiteConfig key: +`sms_price_rials` — باید در `GET /api/v1/sms/wallet/balance` خوانده شود. + +**بعد از پیاده‌سازی:** بخش «SMS Wallet» و «SMS Settings» را به `docs/api/sms.md` اضافه کن. + +--- + +### TASK-15: پرونده بیمار (Patient Records) + +**مرجع:** `docs/phase2_taskes/task-15-patient-records/` + +#### فایل‌های جدید: +``` +src/Patient/Entity/PatientRecord.php +src/Patient/Entity/PatientSession.php +src/Patient/Entity/SessionService.php +src/Patient/Repository/PatientRecordRepository.php +src/Patient/Repository/PatientSessionRepository.php +src/Patient/Service/PatientService.php +src/Patient/Controller/PatientController.php +migrations/Version_YYYYMMDD_patient.php +``` + +#### فایل تغییرکننده: +- `src/Appointment/Controller/AppointmentController.php::updateStatus()` — بعد از persist + +#### PatientRecord: `id`, `uuid`, `entityType`, `entityId`, `user` (FK→User ON DELETE RESTRICT), +`createdByType` (VARCHAR 15: doctor/secretary/system), `createdById` (INT), `createdAt`. +UNIQUE: `(entity_type, entity_id, user_id)`. + +#### PatientSession: `id`, `uuid`, `record` (FK→PatientRecord CASCADE), `appointment` (FK nullable SET NULL), +`insuranceBaseId` (INT nullable FK→categories), `insuranceSupplementaryId` (INT nullable FK→categories), +`visitPriceRials` (INT=0), `baseInsuranceDiscountPercent` (DECIMAL 5,2=0), +`supplementaryDiscountPercent` (DECIMAL 5,2=0), `servicesTotalRials` (INT=0), +`finalPriceRials` (INT=0), `paymentMethod` (VARCHAR 15=pending), `notes` (TEXT nullable), +`createdAt`, `updatedAt`. + +#### SessionService (entity): `id`, `uuid`, `session` (FK→PatientSession CASCADE), +`serviceItem` (FK→ServiceItem ON DELETE RESTRICT), `staff` (FK→ClinicStaff nullable SET NULL), +`priceRials` (INT — کپی در زمان ثبت)، `createdAt`. + +#### PatientService: +```php +public function calculateFinalPrice( + int $visitPrice, float $baseDiscount, float $suppDiscount, array $serviceItems +): array { + $afterBase = $visitPrice * (1 - $baseDiscount / 100); + $afterSupp = $afterBase * (1 - $suppDiscount / 100); + $servicesTotal = array_sum(array_column($serviceItems, 'price_rials')); + return [ + 'services_total_rials' => $servicesTotal, + 'final_price_rials' => (int) round($afterSupp) + $servicesTotal, + ]; +} + +public function autoCreateOnAppointmentConfirm(Appointment $appointment): void +{ + // entityType/entityId از appointment doctor یا clinic + // if !hasFeature('patient_records') → return (نه خطا) + // findOrCreate PatientRecord با UNIQUE constraint + // ایجاد PatientSession با appointment_id, visit_price=0, payment_method='pending' +} +``` + +#### PatientController endpoints: +``` +GET /api/v1/patients → paginated + ?search= — Basic+ gate +POST /api/v1/patient → Basic+ gate — { user_uuid } +GET /api/v1/patient/{uuid} → Basic+ gate +GET /api/v1/patient/{uuid}/sessions → paginated +POST /api/v1/patient/{uuid}/session → { appointment_uuid?, insurance_base_uuid?, visit_price_rials, base_insurance_discount_percent, supplementary_discount_percent, payment_method, notes?, services: [{service_item_uuid, staff_uuid?}] } +PATCH /api/v1/session/{uuid} → ویرایش notes و payment_method +``` + +#### تغییر AppointmentController::updateStatus(): +```php +// بعد از $this->em->flush()، اگر $newStatus === Appointment::STATUS_CONFIRMED: +$this->patientService->autoCreateOnAppointmentConfirm($appointment); +``` + +#### Error codes جدید: +```php +const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND'; // 'پرونده بیمار یافت نشد' +const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND'; // 'مراجعه یافت نشد' +``` + +**بعد از پیاده‌سازی:** فایل `docs/api/patient.md` بساز. + +--- + +### TASK-16: داشبورد هوشمند (Smart Dashboard) + +**مرجع:** `docs/phase2_taskes/task-16-smart-dashboard/` + +#### فایل‌های تغییرکننده (هیچ فایل جدیدی نیست): +- `src/Dashboard/Controller/DashboardController.php` +- `src/Admin/Controller/AdminApiController.php` + +#### تغییر DashboardController — اضافه کردن `from`/`to`: +```php +// متدهای clinic() و doctor(): +$from = (int) $request->query->get('from', strtotime('-30 days')); +$to = (int) $request->query->get('to', time()); + +// فیلدهای جدید در response: +'sms_wallet_balance' => $this->smsWalletService->getBalance($entityType, $entityId), +'unique_patients_count' => $this->patientRecordRepo->countUnique($entityType, $entityId, $from, $to), +'revenue_period_rials' => $this->patientSessionRepo->sumRevenue($entityType, $entityId, $from, $to), +``` + +#### تغییر AdminApiController::dashboardCharts(): +```php +$from = (int) $request->query->get('from', strtotime('-30 days')); +$to = (int) $request->query->get('to', time()); + +// appointments_by_day: DQL با FLOOR(a.createdAt / 86400) * 86400 AS date, COUNT +// revenue_by_day: از patient_sessions در بازه +// subscription_sales_by_plan: از clinic_subscriptions در بازه (is_trial=0) +``` + +#### PatientRecordRepository متد جدید: +```php +public function countUnique(string $entityType, int $entityId, int $from, int $to): int +// COUNT(DISTINCT ps_session.user) با JOIN patient_sessions روی بازه +``` + +#### PatientSessionRepository متد جدید: +```php +public function sumRevenue(string $entityType, int $entityId, int $from, int $to): int +// SUM(final_price_rials) با JOIN patient_records +``` + +**بعد از پیاده‌سازی:** بخش «فیلتر زمانی» و «فیلدهای جدید» را به `docs/api/dashboard.md` اضافه کن. + +--- + +## نکات مهم برای همه تسک‌ها + +### resolveEntityFromJwt — helper مشترک +هر controller باید entity_type و entity_id کاربر جاری را از JWT بخواند: +```php +// از $this->getUser() (که User entity است) → db_key و db_uuid +// db_key = 'doctor' یا 'clinic' +// db_uuid → resolve به id از repository مربوطه +// این منطق باید در BaseController یا یک trait مشترک باشد +// بررسی کن آیا getUser()->getDbKey() / getDbUuid() موجود است +``` + +### migration بعد از هر تسک: +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/console cache:clear +``` + +### بررسی route بعد از هر تسک: +```bash +ddev exec php bin/console debug:router | grep "api/v1/staff\|subscription\|service-section\|sms/wallet\|patient" +``` + +### ترتیب مستندسازی (docs/api/): +| تسک | فایل | +|-----|------| +| task-10 | `docs/api/staff.md` ← **ایجاد جدید** | +| task-11 | `docs/api/subscription.md` ← **ایجاد جدید** | +| task-12 | `docs/api/secretary.md` ← **اضافه کردن بخش** | +| task-13 | `docs/api/clinic-services.md` ← **ایجاد جدید** | +| task-14 | `docs/api/sms.md` ← **اضافه کردن بخش** | +| task-15 | `docs/api/patient.md` ← **ایجاد جدید** | +| task-16 | `docs/api/dashboard.md` ← **اضافه کردن بخش** | + +هر فایل doc باید داشته باشد: method/path، permission، request body، response JSON، error codes.