# فاز ۲ — نشانه‌گذاری 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 §۴).
