Files
clinicpro/.claude/prompt/implement-phase2-backend.md
T

23 KiB

پیاده‌سازی فاز ۲ — 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 (الگو)

#[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

// اشتباه — 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 — وضعیت واقعی فعلی

// 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 — وضعیت واقعی

// src/Payment/Entity/Payment.php
public const TYPE_APPOINTMENT  = 'appointment';
public const TYPE_SUBSCRIPTION = 'subscription';
// TYPE_SMS_WALLET هنوز وجود ندارد — باید اضافه شود

SmsService — signature واقعی

// 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 — وضعیت واقعی

// 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 — متدهای الزامی:

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:

// 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:

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():

// وضعیت فعلی (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:

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 با استفاده در پرونده:

// 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 جدید:

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:

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:

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:

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():

// بعد از $this->em->flush()، اگر $newStatus === Appointment::STATUS_CONFIRMED:
$this->patientService->autoCreateOnAppointmentConfirm($appointment);

Error codes جدید:

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:

// متدهای 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():

$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 متد جدید:

public function countUnique(string $entityType, int $entityId, int $from, int $to): int
// COUNT(DISTINCT ps_session.user) با JOIN patient_sessions روی بازه

PatientSessionRepository متد جدید:

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 بخواند:

// از $this->getUser() (که User entity است) → db_key و db_uuid
// db_key = 'doctor' یا 'clinic'
// db_uuid → resolve به id از repository مربوطه
// این منطق باید در BaseController یا یک trait مشترک باشد
// بررسی کن آیا getUser()->getDbKey() / getDbUuid() موجود است

migration بعد از هر تسک:

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 بعد از هر تسک:

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.