feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules. - Updated unique constraints and indexes to accommodate the new clinic context. feat(command): create AssignScheduleClinicCommand to move schedules - Added a command to move a doctor's personal weekly schedule into a clinic context. - Implemented checks to ensure sessions align with the target clinic. feat(context): implement EntityContext and EntityContextResolver - Created EntityContext to represent the effective working environment of a request (doctor or clinic). - Developed EntityContextResolver to determine the execution context based on user roles and active contexts. test: add ServiceModeContextTest for appointment scheduling - Implemented tests to ensure service booking respects clinic and personal contexts. - Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
# جداسازی Context کلینیک و محیط شخصی پزشک در تنظیمات نوبتدهی
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend Symfony + پنل ادمین React)
|
||||
|
||||
## زمینه
|
||||
|
||||
در مسیر `admin/doctors/{doctorUuid}` (پنل کلینیک) هنگام ذخیرهٔ تنظیمات نوبتدهی برای پزشک عضو کلینیک (دکتر تست، موبایل `09100652121`، کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`) با انتخاب حالت «نوبتدهی سرویسی» خطای زیر برمیگردد:
|
||||
|
||||
> برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است
|
||||
|
||||
در حالی که کلینیک سرویسهای bookable دارد. علت: شمارش سرویسها همیشه با `entity_type='doctor'` انجام میشود و هیچوقت سرویسهای کلینیک را نمیبیند.
|
||||
|
||||
اما این فقط علامتِ یک مشکل معماری بزرگتر است: **کل مدل تنظیمات نوبتدهی، context ندارد.** `WeeklySchedule` یک رابطهٔ `OneToOne` با `doctor` دارد و یک unique constraint روی `doctor_id`؛ یعنی یک پزشک که هم مطب شخصی دارد و هم عضو یک یا چند کلینیک است، فقط **یک** برنامهٔ نوبتدهی در کل سیستم دارد. سرویسها ولی polymorphic هستند (`service_sections.entity_type` = `doctor|clinic`) و کاملاً از هم جدا.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
دو Context باید کاملاً از هم جدا شوند:
|
||||
|
||||
| Context | مالک تنظیمات | سرویسهای قابل استفاده | آدرسهای قابل انتخاب |
|
||||
|---|---|---|---|
|
||||
| محیط شخصی پزشک | `doctor` | فقط `entity_type='doctor', entity_id=doctor.id` | فقط `DoctorAddress` با `type=personal` (یا `clinic_id IS NULL`) |
|
||||
| محیط مدیریت کلینیک | `(doctor, clinic)` | فقط `entity_type='clinic', entity_id=clinic.id` | فقط آدرسهای همان کلینیک |
|
||||
|
||||
قوانین:
|
||||
|
||||
1. پزشک در محیط شخصی **نباید** به سرویسها، آدرسها یا تنظیمات کلینیک دسترسی داشته باشد.
|
||||
2. کلینیک در محیط خودش برای پزشک عضو، **باید** بتواند از سرویسهای کلینیک استفاده کند.
|
||||
3. یک پزشک باید بتواند برای مطب شخصی و برای هر کلینیک، برنامهٔ نوبتدهی مستقل داشته باشد.
|
||||
4. `booking_mode` (slot/service) در هر context مستقل قفل میشود، نه سراسری.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|---|---|
|
||||
| `src/Appointment/Entity/WeeklySchedule.php` | Entity تنظیمات نوبتدهی — `OneToOne` با doctor، بدون clinic |
|
||||
| `src/Appointment/Controller/AppointmentSettingsController.php` | همهٔ endpointهای تنظیمات؛ محل خطا و محل authorization |
|
||||
| `src/ClinicService/Repository/ServiceItemRepository.php` | `countBookableByEntity()` / `findBookableByEntity()` |
|
||||
| `src/ClinicService/Entity/ServiceSection.php` | مالکیت polymorphic سرویس (`entityType`/`entityId`) |
|
||||
| `src/ClinicService/Entity/ServiceItem.php` | فلگ `bookable` |
|
||||
| `src/ClinicService/Controller/ClinicServiceController.php` | `resolveEntity()` — تشخیص context از روی role |
|
||||
| `src/Doctor/Entity/DoctorAddress.php` | آدرس با `clinicId` و `type` |
|
||||
| `src/Appointment/Controller/AppointmentController.php:234-262` | لیست عمومی سرویسهای bookable پزشک |
|
||||
| `src/Auth/Entity/UserActiveContext.php` | context فعال کاربر (فقط `db_uuid`) |
|
||||
| `assets/admin/pages/AppointmentSettingsPage.tsx` | صفحهٔ شخصی پزشک |
|
||||
| `assets/admin/pages/ClinicAppointmentSettingsPage.tsx` | صفحهٔ کلینیک، تب به ازای هر پزشک |
|
||||
| `assets/admin/components/schedule/ScheduleSection.tsx` | کامپوننت مشترک هر دو صفحه |
|
||||
| `assets/admin/stores/authStore.ts` | `context: {type: 'doctor'|'clinic'}` |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### ۱. شمارش سرویس با `doctor` هاردکد
|
||||
|
||||
`src/Appointment/Controller/AppointmentSettingsController.php:57-61`:
|
||||
|
||||
```php
|
||||
private function serviceModeHasNoBookable(array $meta, \App\Doctor\Entity\Doctor $doctor): bool
|
||||
{
|
||||
return ($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) === WeeklySchedule::MODE_SERVICE
|
||||
&& $this->itemRepo->countBookableByEntity('doctor', $doctor->getId()) === 0;
|
||||
}
|
||||
```
|
||||
|
||||
فراخوانی در `:101-103` (POST) و `:144-146` (PATCH):
|
||||
|
||||
```php
|
||||
if ($this->serviceModeHasNoBookable($schedule->getMeta(), $doctor)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است', 422, 'booking_mode');
|
||||
}
|
||||
```
|
||||
|
||||
### ۲. Entity بدون clinic
|
||||
|
||||
`src/Appointment/Entity/WeeklySchedule.php:13-49`:
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor', columns: ['doctor_id'])]
|
||||
class WeeklySchedule
|
||||
{
|
||||
public const MODE_SLOT = 'slot';
|
||||
public const MODE_SERVICE = 'service';
|
||||
...
|
||||
#[ORM\OneToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $setting = [];
|
||||
```
|
||||
|
||||
### ۳. تشخیص context فقط از روی role (و doctor برنده است)
|
||||
|
||||
`src/ClinicService/Controller/ClinicServiceController.php:492-505` — این متد در ۹+ کنترلر تکرار شده:
|
||||
|
||||
```php
|
||||
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];
|
||||
}
|
||||
```
|
||||
|
||||
کاربری که هر دو role را دارد، همیشه بهعنوان doctor حل میشود و هرگز سرویسهای کلینیکش را نمیبیند.
|
||||
|
||||
### ۴. Authorization از کلینیک عبور میکند ولی context را حمل نمیکند
|
||||
|
||||
`src/Appointment/Controller/AppointmentSettingsController.php:437-450`:
|
||||
|
||||
```php
|
||||
private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) {
|
||||
if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
```
|
||||
|
||||
مالک کلینیک مجاز است بنویسد، اما هیچجا مشخص نمیشود که این نوشتن «در context کلینیک» است.
|
||||
|
||||
### ۵. فرانت context را ارسال نمیکند
|
||||
|
||||
`assets/admin/components/schedule/ScheduleSection.tsx:546-563`:
|
||||
|
||||
```tsx
|
||||
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta })
|
||||
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta });
|
||||
```
|
||||
|
||||
هر دو صفحهٔ شخصی و کلینیک دقیقاً همین `ScheduleSection` را رندر میکنند و هیچ تفاوتی در payload ندارند.
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. مدلسازی Context در `WeeklySchedule`
|
||||
|
||||
ستون `clinic_id` (nullable) به `weekly_schedules` اضافه شود:
|
||||
|
||||
- `clinic_id IS NULL` → context شخصی پزشک
|
||||
- `clinic_id = X` → context کلینیک X برای همین پزشک
|
||||
|
||||
تغییرات لازم در `src/Appointment/Entity/WeeklySchedule.php`:
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_id'])]
|
||||
class WeeklySchedule
|
||||
{
|
||||
// OneToOne → ManyToOne (یک پزشک چند برنامه دارد: شخصی + هر کلینیک)
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
#[ORM\JoinColumn(name: 'doctor_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
```
|
||||
|
||||
نکته دربارهٔ unique در MySQL/MariaDB: `NULL` در unique index تکراری مجاز است، پس `(doctor_id, NULL)` چند بار میتواند ثبت شود. برای جلوگیری، یا در سطح Repository قبل از insert چک کن، یا بهجای NULL از `clinic_id = 0` استفاده کن. **گزینهٔ توصیهشده: nullable نگهدار و یکتایی را در سرویس/Repository تضمین کن** (سازگارتر با FK).
|
||||
|
||||
Migration بنویس. برای رکوردهای موجود `clinic_id = NULL` بگذار (همه بهعنوان تنظیمات شخصی تفسیر میشوند) — و در توضیح migration این تصمیم را ذکر کن.
|
||||
|
||||
#### تصمیم قطعی دربارهٔ `DateOverride` و `Holiday`
|
||||
|
||||
این دو **معنای متفاوتی** دارند و رفتارشان یکسان نیست:
|
||||
|
||||
**`DateOverride` → همیشه per-context (`clinic_id` مطابق schedule).**
|
||||
یک override یعنی «ساعت کاری این روزِ خاص با برنامهٔ عادی فرق دارد». ساعت کاری خودش per-context است، پس استثنای آن هم per-context است. اگر پزشک در کلینیک پنجشنبه را تا ۱۲ کار کند، هیچ ربطی به مطب شخصیاش ندارد. ستون `clinic_id` nullable اضافه شود و **همیشه با `clinic_id` همان `WeeklySchedule` مقداردهی شود** (NULL = context شخصی). عملاً بهتر است `DateOverride` به `WeeklySchedule` رفرنس بدهد نه به `Doctor`، ولی برای کمکردن ریسک migration، `(doctor_id, clinic_id)` کافی است.
|
||||
|
||||
**`Holiday` → پیشفرض سراسری (doctor-level)، با امکان محدودسازی به یک context.**
|
||||
تعطیلی یعنی «پزشک آن روز نیست» — یک واقعیت فیزیکی است. پزشکی که در سفر یا مرخصی است، همزمان در مطب شخصی و در کلینیک غایب است؛ اگر per-context باشد، پزشک باید یک مرخصی را N بار ثبت کند و فراموشکردن یکی از آنها = نوبتگرفتن بیمار برای روزی که پزشک نیست. این بدترین خطای ممکن در این دامنه است.
|
||||
|
||||
پس `clinic_id` nullable با این معنا:
|
||||
|
||||
| `clinic_id` | معنی |
|
||||
|---|---|
|
||||
| `NULL` | پزشک آن روز در **هیچ** محلی نیست — روی همهٔ contextها اثر میگذارد |
|
||||
| `X` | پزشک آن روز فقط در کلینیک X نیست (مطب شخصی و بقیه کلینیکها باز) |
|
||||
|
||||
محاسبهٔ تعطیلی مؤثر برای یک context، **اجتماع** دو مجموعه است:
|
||||
|
||||
```php
|
||||
// در HolidayRepository
|
||||
->where('h.doctor = :doctor')
|
||||
->andWhere('h.clinic IS NULL OR h.clinic = :clinic')
|
||||
```
|
||||
|
||||
قواعد نوشتن (اجباری، در سرویس اعمال شود):
|
||||
|
||||
- مالک/کارمند کلینیک فقط میتواند `Holiday` با `clinic_id = <کلینیک خودش>` بسازد یا حذف کند. تلاش برای ساخت تعطیلی سراسری (`clinic_id = NULL`) → 403. دلیل: کلینیک نباید بتواند مطب شخصی پزشک را تعطیل کند.
|
||||
- خودِ پزشک در context شخصی میتواند هر دو نوع را بسازد، ولی UI باید صریح بپرسد. یک انتخاب دوتایی در فرم ثبت تعطیلی:
|
||||
- «در همهٔ محلها نیستم» → `clinic_id = NULL` (پیشفرض)
|
||||
- «فقط در …» → انتخاب یک محل
|
||||
- تعطیلی سراسریِ ساختهشده توسط پزشک، در پنل کلینیک **فقط-خواندنی** نمایش داده شود (کلینیک باید ببیند پزشک نیست، ولی نتواند حذفش کند).
|
||||
|
||||
Migration: همهٔ رکوردهای موجود `Holiday` و `DateOverride` با `clinic_id = NULL` بمانند — برای `Holiday` معنایش دقیقاً همان رفتار فعلی است (سراسری)، برای `DateOverride` یعنی به context شخصی نسبت داده میشوند که با تصمیم بند ۱ سازگار است.
|
||||
|
||||
نکتهٔ مرزی: «کلینیک کلاً تعطیل است» (برای همهٔ پزشکان) با این مدل بیان نمیشود و نیاز به یک `ClinicHoliday` جدا دارد. **خارج از scope این تسک** — فقط در `docs/` بهعنوان کار بعدی ثبت شود.
|
||||
|
||||
### ۲. یک سرویس مرکزی برای حل Context
|
||||
|
||||
بهجای تکرار `resolveEntity()` در ۹ کنترلر، یک سرویس بساز:
|
||||
|
||||
`src/Common/Service/EntityContextResolver.php` (یا محل مناسب مطابق ساختار موجود):
|
||||
|
||||
```php
|
||||
final class EntityContextResolver
|
||||
{
|
||||
/**
|
||||
* context مؤثر را برمیگرداند: ['doctor'|'clinic', id, ?Clinic]
|
||||
* اولویت: clinic_uuid صریح در request > UserActiveContext > role
|
||||
*/
|
||||
public function resolve(User $user, ?string $clinicUuid = null): EntityContext;
|
||||
|
||||
/** آیا این کاربر مجاز است در context کلینیک دادهشده عمل کند؟ */
|
||||
public function assertCanActAs(User $user, EntityContext $ctx): void;
|
||||
}
|
||||
```
|
||||
|
||||
قواعد:
|
||||
|
||||
- اگر `clinic_uuid` در درخواست آمد → context کلینیک، **مشروط به** اینکه `permChecker->can($user, $clinic, ...)` مجاز باشد؛ در غیر این صورت 403.
|
||||
- اگر نیامد → از `UserActiveContext` بخوان (`src/Auth/Entity/UserActiveContext.php`).
|
||||
- اگر آن هم نبود → fallback به منطق فعلی مبتنی بر role.
|
||||
|
||||
**مهم:** اولویت فعلی که `ROLE_DOCTOR` را بر `ROLE_CLINIC` مقدم میکند، برای کاربر دو-نقشی اشتباه است. با این سرویس، `UserActiveContext` باید تعیینکننده باشد.
|
||||
|
||||
سپس `resolveEntity()` را در کنترلرهای موجود (ClinicService, Inventory, Patient, Staff, Billing, Insurance, Subscription, Tag, Sms) با این سرویس جایگزین کن. اگر ریسک این refactor بزرگ بود، **حداقل `ClinicServiceController` و `AppointmentSettingsController` را مهاجرت بده** و بقیه را در یک TODO مستند کن.
|
||||
|
||||
### ۳. اصلاح validation سرویس bookable بر اساس Context
|
||||
|
||||
در `AppointmentSettingsController`:
|
||||
|
||||
```php
|
||||
private function serviceModeHasNoBookable(array $meta, EntityContext $ctx): bool
|
||||
{
|
||||
if (($meta['booking_mode'] ?? WeeklySchedule::MODE_SLOT) !== WeeklySchedule::MODE_SERVICE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->itemRepo->countBookableByEntity($ctx->type, $ctx->id) === 0;
|
||||
}
|
||||
```
|
||||
|
||||
و پیام خطا بسته به context، دقیقتر شود:
|
||||
|
||||
```php
|
||||
$msg = $ctx->type === 'clinic'
|
||||
? 'برای نوبتدهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبتدهی» داشته باشد'
|
||||
: 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است';
|
||||
```
|
||||
|
||||
### ۴. محدودسازی آدرسها بر اساس Context
|
||||
|
||||
`GET /api/v1/appointment-settings/available-locations/{doctorUuid}` (`:399`) الان همهٔ آدرسهای شخصی + همهٔ کلینیکها را union میکند:
|
||||
|
||||
```php
|
||||
$clinics = $this->clinicRepo->findByDoctor($doctor);
|
||||
$clinicIds = array_map(fn(Clinic $c) => $c->getId(), $clinics);
|
||||
$addresses = $this->addressRepo->findAvailableForDoctor($doctor, $clinicIds);
|
||||
```
|
||||
|
||||
باید پارامتر `?clinic_uuid=` بپذیرد:
|
||||
|
||||
- با `clinic_uuid` → فقط آدرسهای همان کلینیک
|
||||
- بدون آن (context شخصی) → فقط `DoctorAddress` با `type = TYPE_PERSONAL` / `clinicId IS NULL`
|
||||
|
||||
همچنین در `validateSessionsHaveLocation()` (`:456-466`) اضافه کن که `location_id` انتخابشده حتماً متعلق به همان context باشد؛ الان هر آدرسی پذیرفته میشود.
|
||||
|
||||
### ۵. لیست سرویسها برای context
|
||||
|
||||
الان هیچ endpointای برای «سرویسهای bookable یک پزشک در یک کلینیک» وجود ندارد؛ `GET /api/v1/service-items` (`ClinicServiceController:217`) owner را از کاربر لاگینشده میگیرد.
|
||||
|
||||
- `GET /api/v1/service-items` باید `?clinic_uuid=` بپذیرد و از `EntityContextResolver` استفاده کند.
|
||||
- `AppointmentController.php:234-262` که `findBookableByEntity('doctor', ...)` را هاردکد کرده، باید context را از `WeeklySchedule` مربوطه (که حالا `clinic` دارد) استخراج کند — نه از role. این مسیر عمومی است و `nobat724_front` مصرفکنندهٔ آن است.
|
||||
|
||||
### ۶. تغییرات endpointهای تنظیمات نوبتدهی
|
||||
|
||||
همهٔ endpointهای `AppointmentSettingsController` باید context بپذیرند:
|
||||
|
||||
- POST `/api/v1/appointment-settings/weekly-schedule` → بدنه `clinic_uuid` اختیاری
|
||||
- GET/PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}` → query `?clinic_uuid=`
|
||||
- `WeeklyScheduleRepository` متد `findOneByDoctorAndClinic(Doctor $d, ?Clinic $c)` بگیرد؛ همهٔ `findOneBy(['doctor' => ...])`ها بهروز شوند.
|
||||
- `assertModeImmutable()` باید mode را از schedule همان context بخواند، نه از تنها schedule پزشک.
|
||||
|
||||
پاسخها طبق `BaseController` با `$this->success()` / `$this->error()` بمانند.
|
||||
|
||||
### ۷. پنل ادمین React
|
||||
|
||||
- `assets/admin/components/schedule/ScheduleSection.tsx` یک prop جدید `clinicUuid?: string` بگیرد و در هر دو فراخوانی POST/PATCH و در query key و در fetch آدرسها آن را ارسال کند.
|
||||
- `AppointmentSettingsPage.tsx` (شخصی) → `clinicUuid` ندهد.
|
||||
- `ClinicAppointmentSettingsPage.tsx` → `clinicUuid={clinicUuid}` بدهد.
|
||||
- query keyهای React Query حتماً شامل `clinicUuid` شوند، وگرنه cache بین دو context نشت میکند.
|
||||
- متن راهنمای `ScheduleSection.tsx:715` بسته به context متفاوت شود: در کلینیک به بخش سرویسهای کلینیک ارجاع دهد.
|
||||
|
||||
### ۸. مستندات و تست
|
||||
|
||||
- فایلهای `docs/api/` مربوط به appointment-settings و service-items با پارامتر جدید `clinic_uuid` بهروز شوند (قانون ثابت پروژه).
|
||||
- تست موجود `tests/Appointment/AppointmentSettingsListOwnershipTest.php` را گسترش بده؛ حداقل این سناریوها:
|
||||
1. پزشک عضو کلینیک، در context شخصی، mode=service با صفر سرویس شخصی → 422.
|
||||
2. همان پزشک در context کلینیک که کلینیک سرویس bookable دارد → 200.
|
||||
3. پزشک در context شخصی نمیتواند `location_id` متعلق به کلینیک را انتخاب کند → 422.
|
||||
4. دو schedule مستقل برای یک پزشک (شخصی + کلینیک) همزمان ذخیره میشوند و mode مستقل قفل میشود.
|
||||
5. کاربری بدون permission روی کلینیک، با `clinic_uuid` آن کلینیک → 403.
|
||||
|
||||
### ۹. داشبورد پزشک دعوتشده در context کلینیک
|
||||
|
||||
**مشکل مشاهدهشده:** پزشک دعوتشده («دکتر دعوت تست ۲») وقتی داخل محیط کلینیک «علی بهروزی» است، داشبورد کاملِ پزشک را میبیند: «میزان درآمد»، «کل پرداختیها»، «پرداختیهای امروز»، «تعداد کل مراجعین» و کارت «کلینیکهای من». این دادهها به context شخصی پزشک تعلق دارند و نباید در محیط کلینیک نمایش داده شوند. علاوه بر این، پزشک دعوتشده اصلاً نباید اطلاعات مالی ببیند.
|
||||
|
||||
**ریشه:** انتخاب داشبورد فقط بر اساس `primaryRole` است و `context.scope` نادیده گرفته میشود.
|
||||
|
||||
`assets/admin/pages/DashboardPage.tsx:1116`:
|
||||
|
||||
```tsx
|
||||
export default function DashboardPage() {
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
if (!primaryRole) return <LoadingSkeleton />;
|
||||
if (primaryRole === 'admin') return <AdminDashboard />;
|
||||
if (primaryRole === 'clinic') return <ClinicDashboard />;
|
||||
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
||||
...
|
||||
```
|
||||
|
||||
در حالی که Sidebar **دقیقاً همین تمایز را میشناسد** — `assets/admin/components/layout/Sidebar.tsx:60-83`:
|
||||
|
||||
```tsx
|
||||
if (primaryRole === "doctor" && scope === "clinic") {
|
||||
const items: SectionItem[] = [
|
||||
{ to: "/admin/dashboard", icon: ChartBarIcon, label: "داشبورد" },
|
||||
];
|
||||
if (can("appointments", "view")) { ... }
|
||||
if (can("patients", "view")) { ... }
|
||||
return [{ label: "عمومی", items }];
|
||||
}
|
||||
```
|
||||
|
||||
منبع `scope`: `src/Auth/Controller/AuthController.php:700-729` — پزشک دعوتشده `role='doctor'`, `scope='clinic'`, `permissions` از `ClinicDoctorPermission`؛ مالک کلینیک `role='clinic'`, `scope=null`, `permissions=null`.
|
||||
|
||||
**وظایف:**
|
||||
|
||||
1. در `DashboardPage.tsx` قبل از dispatch، `scope` را هم بخوان و یک شاخهٔ جدید اضافه کن:
|
||||
|
||||
```tsx
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const scope = useAuthStore(s => s.context?.scope ?? null);
|
||||
...
|
||||
if (primaryRole === 'doctor' && scope === 'clinic') return <InvitedDoctorDashboard />;
|
||||
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
||||
```
|
||||
|
||||
2. `InvitedDoctorDashboard` فقط اینها را نشان دهد:
|
||||
- «تعداد نوبتهای امروز» (محدود به نوبتهای همین پزشک در همین کلینیک)
|
||||
- «لیست نوبتهای جدید» همین پزشک در همین کلینیک
|
||||
- در صورت داشتن `can('patients','view')`، «تعداد مراجعین» همین context
|
||||
|
||||
و اینها **حذف** شوند: «میزان درآمد»، «کل پرداختیها»، «پرداختیهای امروز»، «نمودار درآمد»، کارت «کلینیکهای من» (`DashboardPage.tsx:851`)، و کارت دعوتهای کلینیک (`DoctorClinicInvitationsCard`, `:709`) — دعوتها فقط در context شخصی معنا دارند.
|
||||
|
||||
کارتها بر اساس `permissions` همان context نمایش داده شوند (همان `usePermissions()` که Sidebar استفاده میکند)، نه صرفاً hardcode.
|
||||
|
||||
3. **Backend مهمتر است — مخفیکردن در UI کافی نیست.** `src/Dashboard/Controller/DashboardController.php:180-182` (`GET /api/v1/dashboard/doctor`) داده را از `doctorRepo->findByUser($user)` میگیرد و روی **همهٔ کلینیکها + مطب شخصی** جمع میزند؛ `UserActiveContextRepository` تزریق شده (`:34`) ولی مصرف نمیشود. پزشک دعوتشده الان میتواند مستقیماً این endpoint را صدا بزند و درآمد شخصیاش را بگیرد.
|
||||
|
||||
- `?clinic_uuid=` بپذیرد و از `EntityContextResolver` (وظیفهٔ ۲) استفاده کند.
|
||||
- وقتی context کلینیک است: فیلدهای مالی (`revenue_period_rials`, `today_payments_rials`, `charts.revenue_by_day`) در پاسخ **قرار نگیرند** مگر اینکه `permChecker` مجوز مالی (`billing`/`payments` view) برای آن پزشک در آن کلینیک بدهد.
|
||||
- آمار نوبت/بیمار به نوبتهای همان پزشک در همان کلینیک محدود شود، نه همهٔ کلینیکها.
|
||||
- `sms_balance` هم در context کلینیک نباید از کیف پول شخصی پزشک خوانده شود.
|
||||
|
||||
4. مسیر `/admin/dashboard` در `assets/admin/App.tsx:171` هیچ role gate ندارد؛ لازم نیست gate اضافه شود (خود صفحه dispatch میکند) اما مطمئن شو `RoleRoute` مسیرهای مالی را برای `scope === 'clinic'` مسدود میکند.
|
||||
|
||||
5. تست: پزشک دعوتشده در context کلینیک، `GET /api/v1/dashboard/doctor?clinic_uuid=...` → پاسخ نباید هیچ فیلد مالی داشته باشد؛ و بدون `clinic_uuid` وقتی active context کلینیک است، نتیجه باید همان محدودیت را داشته باشد.
|
||||
|
||||
### ۱۰. قرارداد عمومی برای چند schedule (مصرفکننده: `nobat724_front`)
|
||||
|
||||
**تصمیم قطعی: همهٔ scheduleها نمایش داده شوند، تفکیکشده بر اساس محل نوبتدهی.**
|
||||
|
||||
انتخاب یکی و پنهانکردن بقیه یعنی حذف ظرفیت واقعی پزشک از سایت — پزشکی که سهشنبهها فقط در کلینیک است، آن روز اصلاً قابل رزرو نخواهد بود. ضمناً قیمت و سرویسها بین محلها فرق میکند، پس بیمار باید محل را آگاهانه انتخاب کند، نه اینکه سیستم بهجایش تصمیم بگیرد.
|
||||
|
||||
قرارداد API عمومی — بهجای یک آبجکت، آرایهای از «محلهای نوبتدهی» برگردد:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"doctor": { "uuid": "...", "name": "..." },
|
||||
"booking_locations": [
|
||||
{
|
||||
"location_uuid": "...",
|
||||
"type": "personal",
|
||||
"title": "مطب شخصی",
|
||||
"address": "...",
|
||||
"clinic_uuid": null,
|
||||
"booking_mode": "slot",
|
||||
"services": [],
|
||||
"next_available_at": 1755000000
|
||||
},
|
||||
{
|
||||
"location_uuid": "...",
|
||||
"type": "clinic",
|
||||
"title": "کلینیک علی بهروزی",
|
||||
"address": "...",
|
||||
"clinic_uuid": "41e325c4-...",
|
||||
"booking_mode": "service",
|
||||
"services": [ { "uuid": "...", "name": "...", "price_rials": 0, "duration_minutes": 20 } ],
|
||||
"next_available_at": 1754900000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
قواعد:
|
||||
|
||||
- **پیشفرض انتخابشده:** محلی با کمترین `next_available_at` (زودترین نوبت آزاد). این هم برای بیمار بهترین است و هم نیاز به قاعدهٔ دلبخواهی «شخصی اول یا کلینیک اول» را حذف میکند. اگر هیچ محلی نوبت آزاد نداشت، ترتیب: شخصی، سپس کلینیکها بر اساس نام.
|
||||
- **لینک مستقیم:** `/doctor/{uuid}?location={location_uuid}` تا هر محل قابل اشتراکگذاری و ایندکس باشد. بدون پارامتر → پیشفرض بالا.
|
||||
- **endpointهای اسلات و ثبت نوبت** باید `location_uuid` (یا `clinic_uuid`) اجباری بگیرند. الان محل را از تنها schedule پزشک استنتاج میکنند؛ با چند schedule این استنتاج غلط میشود و **بیسروصدا نوبت را به محل اشتباه ثبت میکند**. این را بهعنوان یک شکست خاموش جدی در نظر بگیر: تا وقتی این پارامتر اجباری نشده، migration بند ۱ را روی production اجرا نکن.
|
||||
- **سازگاری عقبرو:** تا وقتی `nobat724_front` بهروز نشده، اگر پزشک فقط یک schedule دارد (اکثریت مطلق دادههای فعلی)، پاسخ قدیمی هم در کنار `booking_locations` برگردانده شود؛ بعد از استقرار فرانت حذف شود. این را در `docs/api/appointment.md` صریح علامت بزن.
|
||||
- **JSON-LD:** بهجای یک `openingHoursSpecification`، برای هر محل یک entry جدا با `location` مشخص. یک نود `Physician` با چند `availableAtOrFrom`. پرامپت همتا در `nobat724_front` لازم است.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **سازگاری با داده موجود:** هر پزشکی که الان schedule دارد، بعد از migration باید دقیقاً همان رفتار را در context شخصی ببیند. اگر آن schedule عملاً برای کلینیک تنظیم شده بوده (sessionهایش `location_id` کلینیکی دارند)، migration نمیتواند خودکار تشخیص دهد — این را بهعنوان محدودیت شناختهشده مستند کن و یک اسکریپت console برای انتقال دستی بنویس.
|
||||
- سرویسها polymorphic هستند و **هرگز** بین doctor و clinic مشترک نمیشوند؛ هیچجا سرویسهای دو context را union نکن.
|
||||
- تاریخها Unix timestamp صحیح بمانند؛ رشتههای جدید فارسی و تاریخها شمسی.
|
||||
- در پنل ادمین از `SearchableSelect` استفاده کن، نه `<select>` بومی.
|
||||
- **نشت داده مالی:** وظیفهٔ ۹ فقط یک مسئلهٔ UI نیست — `GET /api/v1/dashboard/doctor` الان درآمد شخصی پزشک را بدون هیچ فیلتر contextی برمیگرداند. اصلاح backend اجباری است.
|
||||
- ترتیب پیادهسازی پیشنهادی: (۱) Entity + migration → (۲) `EntityContextResolver` → (۳) کنترلر تنظیمات + validation → (۴) آدرسها و سرویسها → (۵) فرانت → (۶) `AppointmentController` عمومی → (۷) داشبورد پزشک دعوتشده (وظیفهٔ ۹) → (۸) تست و docs. هر مرحله جدا تست شود. وظیفهٔ ۹ به `EntityContextResolver` وابسته است ولی مستقل از migration قابل شروع است.
|
||||
- کاربر تست: `09390039833 / 09390039833`. سناریوی باگ: دکتر تست `09100652121` در کلینیک `41e325c4-e825-4067-8438-5d828ecaee09`.
|
||||
Reference in New Issue
Block a user