- 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.
30 KiB
جداسازی 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 |
فقط آدرسهای همان کلینیک |
قوانین:
- پزشک در محیط شخصی نباید به سرویسها، آدرسها یا تنظیمات کلینیک دسترسی داشته باشد.
- کلینیک در محیط خودش برای پزشک عضو، باید بتواند از سرویسهای کلینیک استفاده کند.
- یک پزشک باید بتواند برای مطب شخصی و برای هر کلینیک، برنامهٔ نوبتدهی مستقل داشته باشد.
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' |
وضعیت فعلی
۱. شمارش سرویس با doctor هاردکد
src/Appointment/Controller/AppointmentSettingsController.php:57-61:
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):
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:
#[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 — این متد در ۹+ کنترلر تکرار شده:
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:
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:
? 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:
#[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، اجتماع دو مجموعه است:
// در 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 (یا محل مناسب مطابق ساختار موجود):
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:
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، دقیقتر شود:
$msg = $ctx->type === 'clinic'
? 'برای نوبتدهی سرویسی، کلینیک باید حداقل یک سرویس با «نمایش در نوبتدهی» داشته باشد'
: 'برای نوبتدهی سرویسی حداقل یک سرویس با «نمایش در نوبتدهی» لازم است';
۴. محدودسازی آدرسها بر اساس Context
GET /api/v1/appointment-settings/available-locations/{doctorUuid} (:399) الان همهٔ آدرسهای شخصی + همهٔ کلینیکها را union میکند:
$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را گسترش بده؛ حداقل این سناریوها:- پزشک عضو کلینیک، در context شخصی، mode=service با صفر سرویس شخصی → 422.
- همان پزشک در context کلینیک که کلینیک سرویس bookable دارد → 200.
- پزشک در context شخصی نمیتواند
location_idمتعلق به کلینیک را انتخاب کند → 422. - دو schedule مستقل برای یک پزشک (شخصی + کلینیک) همزمان ذخیره میشوند و mode مستقل قفل میشود.
- کاربری بدون permission روی کلینیک، با
clinic_uuidآن کلینیک → 403.
۹. داشبورد پزشک دعوتشده در context کلینیک
مشکل مشاهدهشده: پزشک دعوتشده («دکتر دعوت تست ۲») وقتی داخل محیط کلینیک «علی بهروزی» است، داشبورد کاملِ پزشک را میبیند: «میزان درآمد»، «کل پرداختیها»، «پرداختیهای امروز»، «تعداد کل مراجعین» و کارت «کلینیکهای من». این دادهها به context شخصی پزشک تعلق دارند و نباید در محیط کلینیک نمایش داده شوند. علاوه بر این، پزشک دعوتشده اصلاً نباید اطلاعات مالی ببیند.
ریشه: انتخاب داشبورد فقط بر اساس primaryRole است و context.scope نادیده گرفته میشود.
assets/admin/pages/DashboardPage.tsx:1116:
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:
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.
وظایف:
- در
DashboardPage.tsxقبل از dispatch،scopeرا هم بخوان و یک شاخهٔ جدید اضافه کن:
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 />;
-
InvitedDoctorDashboardفقط اینها را نشان دهد:- «تعداد نوبتهای امروز» (محدود به نوبتهای همین پزشک در همین کلینیک)
- «لیست نوبتهای جدید» همین پزشک در همین کلینیک
- در صورت داشتن
can('patients','view')، «تعداد مراجعین» همین context
و اینها حذف شوند: «میزان درآمد»، «کل پرداختیها»، «پرداختیهای امروز»، «نمودار درآمد»، کارت «کلینیکهای من» (
DashboardPage.tsx:851)، و کارت دعوتهای کلینیک (DoctorClinicInvitationsCard,:709) — دعوتها فقط در context شخصی معنا دارند.کارتها بر اساس
permissionsهمان context نمایش داده شوند (همانusePermissions()که Sidebar استفاده میکند)، نه صرفاً hardcode. -
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/paymentsview) برای آن پزشک در آن کلینیک بدهد. - آمار نوبت/بیمار به نوبتهای همان پزشک در همان کلینیک محدود شود، نه همهٔ کلینیکها.
sms_balanceهم در context کلینیک نباید از کیف پول شخصی پزشک خوانده شود.
-
مسیر
/admin/dashboardدرassets/admin/App.tsx:171هیچ role gate ندارد؛ لازم نیست gate اضافه شود (خود صفحه dispatch میکند) اما مطمئن شوRoleRouteمسیرهای مالی را برایscope === 'clinic'مسدود میکند. -
تست: پزشک دعوتشده در context کلینیک،
GET /api/v1/dashboard/doctor?clinic_uuid=...→ پاسخ نباید هیچ فیلد مالی داشته باشد؛ و بدونclinic_uuidوقتی active context کلینیک است، نتیجه باید همان محدودیت را داشته باشد.
۱۰. قرارداد عمومی برای چند schedule (مصرفکننده: nobat724_front)
تصمیم قطعی: همهٔ scheduleها نمایش داده شوند، تفکیکشده بر اساس محل نوبتدهی.
انتخاب یکی و پنهانکردن بقیه یعنی حذف ظرفیت واقعی پزشک از سایت — پزشکی که سهشنبهها فقط در کلینیک است، آن روز اصلاً قابل رزرو نخواهد بود. ضمناً قیمت و سرویسها بین محلها فرق میکند، پس بیمار باید محل را آگاهانه انتخاب کند، نه اینکه سیستم بهجایش تصمیم بگیرد.
قرارداد API عمومی — بهجای یک آبجکت، آرایهای از «محلهای نوبتدهی» برگردد:
{
"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.