Implement SMS panel user flow and patient records system; add wallet charging, automatic reminders, and patient session management with detailed database schema and user flows.

This commit is contained in:
hamed
2026-06-14 21:15:40 +03:30
parent a72a6da621
commit b58aacc37f
28 changed files with 3169 additions and 0 deletions
@@ -0,0 +1,130 @@
# معماری — تسک ۱۳: سرویس‌های کلینیک
## ساختار فایل‌ها
```
src/ClinicService/
├── Controller/
│ └── ClinicServiceController.php
├── Entity/
│ ├── ServiceSection.php
│ └── ServiceItem.php
└── Repository/
├── ServiceSectionRepository.php
└── ServiceItemRepository.php
```
## Entity: ServiceSection
```php
#[ORM\Entity(repositoryClass: ServiceSectionRepository::class)]
#[ORM\Table(name: 'service_sections')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_service_sections_entity')]
class ServiceSection
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 10)]
private string $entityType; // 'doctor' | 'clinic'
#[ORM\Column(type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Entity: ServiceItem
```php
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
#[ORM\Table(name: 'service_items')]
class ServiceItem
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: ServiceSection::class)]
#[ORM\JoinColumn(name: 'section_id', nullable: false, onDelete: 'CASCADE')]
private ServiceSection $section;
// staff nullable — پرسنل انجام‌دهنده
#[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
private ?\App\Staff\Entity\ClinicStaff $staff = null;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(type: 'integer')]
private int $priceRials = 0;
#[ORM\Column(type: 'boolean')]
private bool $active = true;
#[ORM\Column(type: 'integer')]
private int $createdAt;
#[ORM\Column(type: 'integer')]
private int $updatedAt;
}
```
## Controller: ClinicServiceController
```php
#[Route('/api/v1')]
class ClinicServiceController extends BaseController
{
// برای همه write actions → gate check اول:
private function assertServicesFeature(): void
{
[$entityType, $entityId] = $this->resolveEntityContext();
if (!$this->subscriptionService->hasFeature($entityType, $entityId, 'services')) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
}
#[Route('/service-sections', methods: ['GET'])]
public function listSections(): JsonResponse { ... }
#[Route('/service-section', methods: ['POST'])]
public function createSection(Request $request): JsonResponse
{
$this->assertServicesFeature();
// ...
}
// سایر endpoint ها ...
}
```
## نکته DELETE سرویس با استفاده در پرونده
```php
// اگر session_services.service_item_id → این item باشد:
// FK ON DELETE RESTRICT → Doctrine exception → catch و return 409
try {
$this->em->remove($item);
$this->em->flush();
} catch (\Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException $e) {
return $this->error(ErrorCodes::ERR_SERVICE_ITEM_IN_USE, 'این سرویس در پرونده بیمار استفاده شده است', 409);
}
```
@@ -0,0 +1,69 @@
# پایگاه داده — تسک ۱۳: سرویس‌های کلینیک
## جدول: service_sections
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| entity_type | VARCHAR(10) NOT NULL | `'doctor'` \| `'clinic'` |
| entity_id | INT NOT NULL | |
| name | VARCHAR(200) NOT NULL | نام بخش |
| active | TINYINT(1) NOT NULL DEFAULT 1 | |
| created_at | INT NOT NULL | Unix timestamp |
| updated_at | INT NOT NULL | Unix timestamp |
ایندکس:
```sql
INDEX idx_service_sections_entity ON service_sections(entity_type, entity_id)
```
## جدول: service_items
| ستون | نوع | توضیح |
|------|-----|-------|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
| uuid | CHAR(36) UNIQUE NOT NULL | |
| section_id | INT NOT NULL FK→service_sections.id ON DELETE CASCADE | |
| staff_id | INT NULL FK→clinic_staff.id ON DELETE SET NULL | پرسنل انجام‌دهنده (اختیاری) |
| name | VARCHAR(200) NOT NULL | نام سرویس |
| price_rials | INT NOT NULL DEFAULT 0 | |
| active | TINYINT(1) NOT NULL DEFAULT 1 | |
| created_at | INT NOT NULL | |
| updated_at | INT NOT NULL | |
## Migration نمونه
```sql
CREATE TABLE service_sections (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
entity_type VARCHAR(10) NOT NULL,
entity_id INT NOT NULL,
name VARCHAR(200) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
INDEX idx_service_sections_entity (entity_type, entity_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE service_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
section_id INT NOT NULL,
staff_id INT NULL,
name VARCHAR(200) NOT NULL,
price_rials INT NOT NULL DEFAULT 0,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at INT NOT NULL,
updated_at INT NOT NULL,
FOREIGN KEY (section_id) REFERENCES service_sections(id) ON DELETE CASCADE,
FOREIGN KEY (staff_id) REFERENCES clinic_staff(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
## نکات مهم
- `section_id ON DELETE CASCADE` — حذف بخش، همه زیربخش‌ها را هم حذف می‌کند
- `staff_id ON DELETE SET NULL` — غیرفعال‌سازی پرسنل، سرویس را حذف نمی‌کند فقط staff_id=NULL می‌شود
- `session_services.service_item_id` در تسک ۱۵ با `ON DELETE RESTRICT` → اگر item در سشنی باشد، حذف block می‌شود
@@ -0,0 +1,102 @@
# تسک ۱۳: سرویس‌های کلینیک (Clinic Services)
## توضیح
پیاده‌سازی مدیریت سرویس‌های کلینیک در دو سطح: بخش (ServiceSection) و زیربخش (ServiceItem).
دسترسی فقط در پنل **Basic+** — gate: `hasFeature('services')`.
در تسک ۱۵ (پرونده بیمار)، ServiceItems در سشن‌های بیمار استفاده می‌شوند.
## Endpoint ها
| متد | مسیر | Gate | توضیح |
|-----|------|------|-------|
| GET | `/api/v1/service-sections` | Basic+ | لیست بخش‌ها |
| POST | `/api/v1/service-section` | Basic+ | ایجاد بخش |
| PATCH | `/api/v1/service-section/{uuid}` | Basic+ | ویرایش بخش |
| DELETE | `/api/v1/service-section/{uuid}` | Basic+ | حذف بخش |
| GET | `/api/v1/service-items/{sectionUuid}` | Basic+ | لیست زیربخش‌های یک بخش |
| POST | `/api/v1/service-item` | Basic+ | ایجاد زیربخش |
| PATCH | `/api/v1/service-item/{uuid}` | Basic+ | ویرایش زیربخش |
| DELETE | `/api/v1/service-item/{uuid}` | Basic+ | حذف زیربخش |
**نکته DELETE:** حذف سخت مجاز است — اما اگر ServiceItem در session_services استفاده شده باشد، باید با خطا متوقف شود (FK ON DELETE RESTRICT).
## پیش‌نیازها
- **تسک ۱۰** (ClinicStaff — برای staff_id در ServiceItem)
- **تسک ۱۱** (Subscription — hasFeature gate)
## زمان تخمینی
۸ تا ۱۰ ساعت
## نمونه Request
### POST /api/v1/service-section
```json
{
"name": "تزریقات"
}
```
### POST /api/v1/service-item
```json
{
"section_uuid": "uuid-of-section",
"name": "سرم ۵۰۰cc",
"price_rials": 85000,
"staff_uuid": "uuid-of-staff-optional"
}
```
### PATCH /api/v1/service-item/{uuid}
```json
{
"price_rials": 95000,
"staff_uuid": null
}
```
## نمونه Response
### GET /api/v1/service-sections
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "تزریقات",
"active": true,
"items_count": 3
},
{
"uuid": "...",
"name": "فیزیوتراپی",
"active": true,
"items_count": 7
}
]
}
```
### GET /api/v1/service-items/{sectionUuid}
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "سرم ۵۰۰cc",
"price_rials": 85000,
"staff": { "uuid": "...", "full_name": "علی رضایی" },
"active": true
}
]
}
```
## کدهای خطا
| کد | HTTP | توضیح |
|----|------|-------|
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | پنل Basic یا بالاتر لازم است |
| `ERR_SERVICE_SECTION_HAS_ITEMS` | 409 | بخش دارای زیربخش است — ابتدا آنها را حذف کنید |
| `ERR_SERVICE_ITEM_IN_USE` | 409 | سرویس در پرونده بیمار استفاده شده — قابل حذف نیست |
@@ -0,0 +1,99 @@
# جریان کاربری — تسک ۱۳: سرویس‌های کلینیک
## جریان مشاهده و ایجاد سرویس‌ها
```
کاربر وارد صفحه «سرویس‌ها» می‌شود
GET /api/v1/service-sections
→ accordion دو سطحی نمایش داده می‌شود
بر روی هر بخش کلیک می‌کند:
GET /api/v1/service-items/{sectionUuid}
→ زیربخش‌ها نمایش داده می‌شوند
```
## ساختار نمایش (ServicesPage.tsx)
```
┌─────────────────────────────────────────────┐
│ سرویس‌های کلینیک [+ بخش جدید] │
├─────────────────────────────────────────────┤
│ ▼ تزریقات ✏️ 🗑️ │
│ ├─ سرم ۵۰۰cc ۸۵,۰۰۰ ریال ✏️ 🗑️ │
│ ├─ آمپول B12 ۴۵,۰۰۰ ریال ✏️ 🗑️ │
│ └─ [+ سرویس جدید] │
├─────────────────────────────────────────────┤
│ ► فیزیوتراپی ✏️ 🗑️ │
└─────────────────────────────────────────────┘
```
## جریان ایجاد بخش جدید
```
کاربر «+ بخش جدید» کلیک می‌کند → Modal باز می‌شود
POST /api/v1/service-section { name: 'تزریقات' }
├─► 403 ERR_SUBSCRIPTION_REQUIRED → «پنل Basic لازم است»
└─► 201 → accordion به‌روز می‌شود
```
## جریان حذف بخش
```
کاربر روی 🗑️ بخش کلیک می‌کند
ConfirmDialog: «آیا از حذف بخش "تزریقات" مطمئنید؟
همه زیربخش‌های آن هم حذف می‌شوند.»
DELETE /api/v1/service-section/{uuid}
├─► اگر هیچ زیربخشی ندارد → 200 حذف شد
└─► اگر زیربخش دارد → 409 ERR_SERVICE_SECTION_HAS_ITEMS
(یا اگر DELETE CASCADE تعریف شده: همه حذف می‌شوند)
```
## جریان حذف زیربخش
```
DELETE /api/v1/service-item/{uuid}
├─► اگر در هیچ session_services نیست → 200
└─► اگر در session_services استفاده شده → 409 ERR_SERVICE_ITEM_IN_USE
پیام: «این سرویس در پرونده بیمار ثبت شده و قابل حذف نیست.»
```
## جریان انتخاب پرسنل انجام‌دهنده
```
هنگام ایجاد/ویرایش ServiceItem:
GET /api/v1/staff → SearchableSelect از پرسنل فعال
→ «علی رضایی (پرستار)»
→ «مریم نوری (فیزیوتراپیست)»
→ [بدون پرسنل مشخص]
```
## gate check — کاربر Free
```
کاربر Free روی «سرویس‌ها» در sidebar کلیک می‌کند
ServicesPage.tsx → GET /api/v1/service-sections → 403
نمایش banner:
┌─────────────────────────────────────────────┐
│ 🔒 این قابلیت نیاز به پنل Basic یا بالاتر │
│ دارد. │
│ [مشاهده پنل‌ها] │
└─────────────────────────────────────────────┘
```