feat: implement Phase 2 backend tasks including staff management, subscription tiers, secretary completion, clinic services, SMS panel, patient records, and smart dashboard
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user