# فاز ۲ — نشانه‌گذاری tenant روی جدول‌های نوبت‌دهی > پرامپت دوم از سری پنج‌تایی. **پیش‌نیاز: `tenant-01-entity-context-unify.md` باید کامل و سبز شده باشد.** > ۱. `tenant-01-entity-context-unify.md` → **۲. نشانه‌گذاری جدول‌های نوبت (همین فایل)** → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → ۵. `tenant-05-audit-and-docs.md` ## زمینه پس از فاز ۱، تشخیص محیط یک نقطه دارد و `EntityContext::toEntityPair()` جفت `(entity_type, entity_id)` را برمی‌گرداند. اما جدول‌های هستهٔ نوبت‌دهی هنوز این جفت را **ذخیره نمی‌کنند** — آن‌ها با `doctor_id` اجباری و `clinic_id` تهی‌پذیر کار می‌کنند: ```php // src/Appointment/Entity/Appointment.php #[ORM\ManyToOne(targetEntity: Doctor::class)] private Doctor $doctor; // اجباری #[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)] private ?\App\Clinic\Entity\Clinic $clinic = null; // NULL = مطب شخصی ``` نتیجه: هر کوئری‌ای که می‌خواهد «دادهٔ این محیط» را بگیرد، باید خودش `clinic_id IS NULL ? doctor : clinic` را بازسازی کند. همین بازسازی است که در فاز ۱ به‌عنوان تکرار منطق حذف شد — ولی در **لایهٔ کوئری** هنوز باقی است و فاز ۴ (فیلتر خودکار Doctrine) بدون ستون واقعی روی این جدول‌ها اصلاً کار نمی‌کند. ## مشکل / هدف **مشکل:** `appointments`، `weekly_schedules` و `date_overrides` محیط خود را به‌صورت ضمنی و با دو ستون متفاوت نگه می‌دارند؛ `weekly_schedules` و `date_overrides` برای رفع مشکل یکتاییِ `NULL` مجبور به یک ستون تولیدشدهٔ مخصوص MariaDB شده‌اند: ```php // src/Appointment/Entity/WeeklySchedule.php و DateOverride.php #[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')] private int $clinicKey = 0; ``` **هدف:** جفت `(entity_type, entity_id)` روی این سه جدول ذخیره شود، ایندکس‌ها با tenant به‌عنوان ستون پیشرو بازسازی شوند، و هک `clinic_key` حذف شود. ### ⚠️ دو چیزی که عمداً تغییر نمی‌کنند این دو مورد در کد فعلی دلیل مکتوب دارند و **نباید** به بهانهٔ یکسان‌سازی خراب شوند: **۱. کلید یکتایی اسلات، سطح پزشک می‌ماند — نه سطح tenant.** ```php // src/Appointment/Entity/Appointment.php /** * کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط * جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و می‌تواند با محیط * دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی * اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ. */ private function refreshActiveSlotKey(): void { $this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true) ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) : null; } ``` افزودن tenant به `active_slot_key` یک باگ امنیتی-تجاری می‌سازد: همان پزشک هم‌زمان در مطب شخصی و کلینیک رزرو می‌شود. **دست نزن.** **۲. جدول `holidays` نشانه‌گذاری tenant نمی‌شود.** ```php // src/Appointment/Entity/Holiday.php /** * تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است: * «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها * اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست. */ ``` اینجا `clinic = NULL` معنایش «مطب شخصی» **نیست**، معنایش «همهٔ محیط‌ها» است. جفت `(entity_type, entity_id)` نمی‌تواند این را بیان کند و تبدیلش، تعطیلی‌های سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. `holidays` با همان `clinic_id` تهی‌پذیر می‌ماند و در فاز ۴ در whitelist «جدول‌های خارج از فیلتر tenant» قرار می‌گیرد. ## معیار پذیرش - ✅ موفق: بعد از migration، برای هر ردیف `appointments` رابطهٔ زیر برقرار است — قابل تأیید با کوئری در «نحوه تست» وظیفهٔ ۲: `clinic_id IS NULL → (entity_type='doctor', entity_id=doctor_id)` و `clinic_id IS NOT NULL → (entity_type='clinic', entity_id=clinic_id)`. ثبت یک نوبت جدید از پنل (هم در محیط کلینیک، هم در محیط مطب شخصی) جفت را خودکار و درست پر می‌کند. - ❌ خطا: تلاش برای ذخیرهٔ `Appointment` بدون محیطِ حل‌شده (`EntityContext::unknown()`) → `AppException` با کد از `ErrorCodes`، نه `SQLSTATE` خام و نه ردیف با `entity_id = 0`. - ⚠️ مرزی: دو پزشک **متفاوت** در **یک کلینیک** هر کدام `WeeklySchedule` خودشان را دارند → هر دو با `(entity_type='clinic', entity_id=<همان clinic>)` ذخیره می‌شوند و یکتایی نباید نقض شود (به همین دلیل کلید یکتا باید `doctor_id` را هم داشته باشد — وظیفهٔ ۳). همچنین: نوبتی که `clinic_id` دارد ولی آن کلینیک بعداً حذف شده → migration نباید بشکند (وظیفهٔ ۲، بند «ردیف‌های یتیم»). ## فایل‌های مرتبط | فایل | نقش | |------|-----| | `src/Appointment/Entity/Appointment.php` | افزودن جفت tenant + بازسازی ایندکس‌ها | | `src/Appointment/Entity/WeeklySchedule.php` | افزودن جفت + حذف `clinic_key` + بازسازی یکتایی | | `src/Appointment/Entity/DateOverride.php` | همان کار `WeeklySchedule` | | `src/Appointment/Entity/Holiday.php` | **بدون تغییر** — فقط افزودن یادداشت استثنا در docblock | | `src/Shared/Context/EntityContext.php` | منبع جفت (`toEntityPair()`) | | `src/Appointment/Repository/AppointmentRepository.php` | کوئری‌هایی که با `clinic_id IS NULL` محیط را بازسازی می‌کنند | | `src/Appointment/Repository/WeeklyScheduleRepository.php` | کوئری‌های مبتنی بر `clinic_key` | | `src/Appointment/Repository/DateOverrideRepository.php` | همان | | `migrations/` | migration جدید (schema + backfill) | | `tests/Appointment/SlotUniquenessTest.php` | تست‌های موجود که نباید بشکنند | ## وضعیت فعلی ایندکس‌های فعلی `appointments` — هیچ‌کدام tenant را نمی‌شناسند: ```php #[ORM\Entity(repositoryClass: AppointmentRepository::class)] #[ORM\Table(name: 'appointments')] #[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')] #[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')] #[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')] class Appointment { #[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)] private ?string $activeSlotKey = null; ``` یکتایی فعلی `weekly_schedules` و `date_overrides` — وابسته به ستون تولیدشده: ```php #[ORM\Table(name: 'weekly_schedules')] #[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])] #[ORM\Table(name: 'date_overrides')] #[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])] ``` ## وظایف ### ۱. ساخت trait مشترک برای جفت tenant سه entity همان دو ستون را می‌گیرند. به‌جای کپی، یک trait در `src/Shared/Tenant/` بساز. ```php // src/Shared/Tenant/TenantOwnedTrait.php namespace App\Shared\Tenant; use App\Shared\Context\EntityContext; use Doctrine\ORM\Mapping as ORM; /** * جفت مالکیتِ محیط — همان قراردادی که ServiceSection و PatientRecord از قبل دارند. * مقدارها فقط از EntityContext::toEntityPair() می‌آیند تا با فاز ۱ یک منبع بمانند. */ trait TenantOwnedTrait { #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] private string $entityType; #[ORM\Column(name: 'entity_id', type: 'integer')] private int $entityId; public function getEntityType(): string { return $this->entityType; } public function getEntityId(): int { return $this->entityId; } public function assignTenant(EntityContext $context): void { if (!$context->isResolved()) { throw new \InvalidArgumentException('Cannot assign an unresolved tenant context.'); } [$this->entityType, $this->entityId] = $context->toEntityPair(); } } ``` طول `length: 10` عمدی است — با `service_sections` و `clinic_staff` یکی است تا کوئری‌های JOIN بین جدول‌ها collation mismatch ندهند. (`tenant_tags` طول ۲۰ دارد؛ آن ناهماهنگی در فاز ۳ رسیدگی می‌شود، اینجا نه.) **نحوه تست:** `ddev exec php vendor/bin/phpstan analyse src/Shared/Tenant` بدون خطا؛ و تست واحد که `assignTenant(EntityContext::unknown())` استثنا پرتاب کند. --- ### ۲. افزودن جفت به `Appointment` + backfill trait را به entity اضافه کن و در سازنده از `EntityContext` پرش کن. `doctor` و `clinic` **حذف نمی‌شوند** — جفت جدید یک denormalization عمدی است، نه جایگزین: `doctor_id` هنوز برای یکتایی اسلات و تقویم لازم است. migration دو مرحله‌ای بنویس (schema سپس backfill): ```sql -- گام ۱: ستون‌ها تهی‌پذیر اضافه شوند ALTER TABLE appointments ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL; -- گام ۲: backfill — کلینیک اولویت دارد، در نبودش مطب شخصی UPDATE appointments SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'), entity_id = IFNULL(clinic_id, doctor_id); -- گام ۳: ردیف‌های یتیم (clinic_id به کلینیک حذف‌شده اشاره می‌کند) UPDATE appointments a LEFT JOIN clinics c ON c.id = a.clinic_id SET a.entity_type = 'doctor', a.entity_id = a.doctor_id WHERE a.clinic_id IS NOT NULL AND c.id IS NULL; -- گام ۴: تأیید صفر بودن باقی‌مانده — اگر صفر نبود migration باید fail کند -- SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL; -- گام ۵: تازه حالا NOT NULL ALTER TABLE appointments MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL; ``` در متد `up()` migration، بین گام ۴ و ۵ یک `abortIf()` بگذار تا اگر ردیف پر نشده‌ای ماند، migration به‌جای ساختن دادهٔ خراب متوقف شود: ```php $remaining = (int) $this->connection->fetchOne( 'SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL' ); $this->abortIf($remaining > 0, "Backfill left {$remaining} appointments without a tenant."); ``` **نحوه تست:** ```bash ddev exec php bin/console doctrine:migrations:diff --no-interaction ddev exec php bin/console doctrine:migrations:migrate --no-interaction # صحت backfill — هر سه کوئری باید 0 برگردانند ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL" ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE clinic_id IS NULL AND (entity_type<>'doctor' OR entity_id<>doctor_id)" ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments a JOIN clinics c ON c.id=a.clinic_id WHERE a.entity_type<>'clinic' OR a.entity_id<>a.clinic_id" ``` --- ### ۳. `WeeklySchedule` و `DateOverride` — افزودن جفت و حذف `clinic_key` اینجا جفت tenant **جایگزین** `clinic_key` می‌شود، ولی `doctor_id` در کلید یکتا می‌ماند. **چرا `doctor_id` باید در کلید بماند:** در یک کلینیک چند پزشک وجود دارد و هر کدام برنامهٔ هفتگی خودش را دارد. اگر کلید فقط `(entity_type, entity_id)` باشد، پزشک دوم همان کلینیک با نقض یکتایی مواجه می‌شود. این همان حالت مرزی معیار پذیرش است. ```php #[ORM\Table(name: 'weekly_schedules')] #[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant', columns: ['doctor_id', 'entity_type', 'entity_id'])] #[ORM\Table(name: 'date_overrides')] #[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date', columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])] ``` migration: ```sql ALTER TABLE weekly_schedules ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL; UPDATE weekly_schedules SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'), entity_id = IFNULL(clinic_id, doctor_id); -- abortIf روی باقی‌ماندهٔ NULL ALTER TABLE weekly_schedules MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL; DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules; CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules (doctor_id, entity_type, entity_id); ALTER TABLE weekly_schedules DROP COLUMN clinic_key; ``` **ترتیب حیاتی:** `clinic_key` فقط بعد از ساخته شدن ایندکس جدید حذف شود، وگرنه بین دو دستور، جدول بدون هیچ محافظ یکتایی می‌ماند. همین ترتیب برای `date_overrides`. `clinic_id` را نگه دار — چند نقطهٔ کد (از جمله `AssignScheduleClinicCommand` و کوئری‌های `SlotCalculatorService`) هنوز مستقیم به آن وابسته‌اند و حذفش دامنهٔ این فاز را می‌ترکاند. حذفش کار فاز ۵ است، بعد از اینکه فاز ۴ ثابت کرد جفت tenant همه‌جا کافی است. **نحوه تست:** ```bash ddev exec php bin/console doctrine:migrations:migrate --no-interaction ddev exec php bin/phpunit tests/Appointment/ # حالت مرزی: دو پزشک در یک کلینیک ddev exec php bin/console dbal:run-sql "SELECT entity_type, entity_id, COUNT(DISTINCT doctor_id) d FROM weekly_schedules GROUP BY 1,2 HAVING d > 1" # ↑ باید حداقل یک ردیف برگرداند (یعنی چند پزشک در یک کلینیک) و migration نشکسته باشد ddev exec php bin/console dbal:run-sql "SHOW COLUMNS FROM weekly_schedules LIKE 'clinic_key'" # خالی ``` --- ### ۴. بازسازی ایندکس‌های `appointments` با tenant پیشرو هر ایندکسی که برای کوئری‌های «لیست نوبت‌های این محیط» استفاده می‌شود باید tenant را به‌عنوان **ستون اول** داشته باشد، وگرنه MariaDB نمی‌تواند از آن برای فیلتر tenant استفاده کند. ```php #[ORM\Table(name: 'appointments')] // tenant پیشرو — برای لیست‌های پنل که همیشه محیط‌محورند #[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')] #[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')] // بدون tenant — عمدی: یکتایی و تقویم سطح پزشک‌اند، نه محیط #[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')] #[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')] #[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')] ``` `idx_appointments_doctor_slot` را **حذف نکن** — `AppointmentRepository::isSlotTaken()` و `SlotCalculatorService` روی آن کوئری می‌زنند و آن کوئری‌ها ذاتاً سطح پزشک‌اند. بعد از migration، پلن کوئری لیست نوبت‌ها را بررسی کن که واقعاً ایندکس جدید را انتخاب می‌کند. **نحوه تست:** ```bash ddev exec php bin/console dbal:run-sql "EXPLAIN SELECT * FROM appointments WHERE entity_type='clinic' AND entity_id=1 AND slot_start > UNIX_TIMESTAMP() ORDER BY slot_start LIMIT 20" # ستون key باید idx_appointments_tenant_slot باشد، نه NULL و نه ALL ``` --- ### ۵. اتصال نقطهٔ نوشتن به `EntityContext` هر جایی که `Appointment`، `WeeklySchedule` یا `DateOverride` **ساخته** می‌شود باید `assignTenant()` را با `EntityContext` صدا بزند. نقاط ساخت را با این دستور پیدا کن: ```bash ddev exec grep -rn "new Appointment(\|new WeeklySchedule(\|new DateOverride(" src --include="*.php" ``` برای مسیر رزرو عمومی (بیمار، نه کاربر پنل)، محیط از `BookingContextResolver` می‌آید که `?Clinic` برمی‌گرداند؛ تبدیلش: ```php $context = $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::forDoctor($doctor); $appointment->assignTenant($context); ``` **هیچ مقدار پیش‌فرضی نگذار.** اگر محیط حل نشد، `AppException` با کد از `ErrorCodes` پرتاب شود — نه `entity_id = 0` و نه حدس زدن. این همان معیار پذیرش ❌ است. **نحوه تست:** ثبت نوبت واقعی از پنل با کاربر تست (`clinicpro-qa-accounts`) در هر دو محیط، سپس: ```bash ddev exec php bin/console dbal:run-sql "SELECT id, doctor_id, clinic_id, entity_type, entity_id FROM appointments ORDER BY id DESC LIMIT 5" ``` جفت باید با محیطی که نوبت در آن ثبت شده بخواند. --- ### ۶. یادداشت استثنا روی `Holiday` `holidays` نشانه‌گذاری نمی‌شود. برای اینکه فاز ۴ (تست ساختاری روی همهٔ entityها) این را استثنای عمدی بشناسد نه فراموشی، در docblock کلاس یادداشت بگذار: ```php /** * ...docblock موجود... * * عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی * «همهٔ محیط‌ها»، نه «مطب شخصی». جفت tenant نمی‌تواند «همه» را بیان کند و * تبدیلش، تعطیلی سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. * در whitelist فاز ۴ (TenantFilter) ثبت شده است. */ ``` **نحوه تست:** بازبینی دستی؛ فاز ۴ این یادداشت را به whitelist تبدیل می‌کند. ## نکات مهم - **الگو: denormalization عمدی + trait مشترک.** جفت tenant روی `appointments` تکرار اطلاعاتی است که از `clinic_id`/`doctor_id` قابل استخراج بود. دلیل پذیرش تکرار: فیلتر خودکار فاز ۴ و ایندکس tenant-پیشرو، هر دو به یک ستون واقعی نیاز دارند و با شرط `IF(clinic_id IS NULL, ...)` قابل ساخت نیستند. trait به‌جای کلاس پایه انتخاب شده چون این سه entity هیچ رفتار مشترک دیگری ندارند و ارث‌بری Doctrine هزینهٔ نقشه‌برداری اضافه می‌کند. - **`active_slot_key` را دست نزن** (بخش «دو چیزی که عمداً تغییر نمی‌کنند»). اگر تستی از تو خواست آن را عوض کنی، تست غلط است نه کد. - **`clinic_id` و `doctor_id` حذف نمی‌شوند.** این فاز فقط اضافه می‌کند. حذف ستون‌های قدیمی بعد از اثبات کفایت جفت tenant در فاز ۵ بررسی می‌شود. - **ترتیب migration:** ستون تهی‌پذیر → backfill → `abortIf` روی باقی‌ماندهٔ NULL → `NOT NULL` → ایندکس جدید → حذف ایندکس/ستون قدیمی. هر جابه‌جایی در این ترتیب یعنی پنجره‌ای که جدول بدون محافظ یکتایی می‌ماند. - **قبل از migrate روی دادهٔ واقعی، بکاپ بگیر:** `ddev export-db --file=/tmp/pre-tenant-phase2.sql.gz`. این migration دادهٔ موجود را می‌نویسد و rollback خودکار ندارد. - **کلاینت‌های متأثر:** `entity_type`/`entity_id` نباید در پاسخ API ظاهر شوند مگر جایی که از قبل ظاهر می‌شدند. اگر `toArray()` نوبت را عوض کردی، `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` باید دستی بررسی شوند — build آن‌ها خطا نمی‌دهد. - **مستندات:** اگر شکل پاسخ نوبت تغییر کرد، `docs/api/appointment.md` همان جلسه به‌روز شود با JSON واقعیِ خروجی اجرا، نه دست‌ساز (guidelines §۴).