# فاز ۳ — یکسان‌سازی نمایش‌های ناهمگون tenant

> پرامپت سوم از سری پنج‌تایی. **پیش‌نیاز: فازهای ۱ و ۲ کامل و سبز.**
> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → **۳. یکسان‌سازی ستون‌ها (همین فایل)** → ۴. `tenant-04-enforce-tenant-filter.md` → ۵. `tenant-05-audit-and-docs.md`

## زمینه

بعد از فاز ۲، جدول‌های نوبت‌دهی هم جفت `(entity_type, entity_id)` دارند. اما همان مفهوم در بقیهٔ دیتابیس با **چهار املای متفاوت** نوشته شده و فاز ۴ (فیلتر خودکار) نمی‌تواند روی چهار املا کار کند — فیلتر Doctrine روی نام فیلد تصمیم می‌گیرد.

نقشهٔ وضعیت فعلی:

| املا | جدول‌ها |
|---|---|
| `entity_type` + `entity_id` (**هدف**) | `service_sections`, `patient_records`, `clinic_staff`, `clinic_subscriptions`, `sms_wallets`, `sms_settings`, `entity_insurance_pricing`, `tenant_insurances`, `tenant_service_category_settings`, `invoices`, `claims`, `inventory_items`, `inventory_packages`, `tenant_tags` + (از فاز ۲) `appointments`, `weekly_schedules`, `date_overrides` |
| `owner_type` + `owner_id` | `discount_rules` |
| `owner_type` + `clinic_id` تهی‌پذیر | `doctor_secretaries` |
| `db_uuid` مبهم (کلینیک یا پزشک؟) | `user_active_context` |

به‌علاوه یک ناهماهنگی طولی: `entity_type` در ۱۱ جدول `VARCHAR(10)` و در ۴ جدول `VARCHAR(20)` است.

## مشکل / هدف

**مشکل ۱ — فیلتر خودکار روی چهار املا کار نمی‌کند.** `TenantFilter` فاز ۴ با `$meta->hasField('entityType')` تصمیم می‌گیرد؛ `discount_rules` و `doctor_secretaries` بی‌صدا از فیلتر جا می‌مانند — یعنی دقیقاً همان نشتی خاموشی که کل این پروژه برای جلوگیری از آن است.

**مشکل ۲ — `db_uuid` هر بار دو کوئری می‌خواهد.** الگوی زیر در `EntityContextResolver` اجتناب‌ناپذیر است چون خود ستون نمی‌گوید uuid مال چیست:

```php
// src/Shared/Context/EntityContextResolver.php — fromActiveContext()
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
if ($clinic !== null) { ... }

$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
```

**مشکل ۳ — یک باگ واقعی در یکتایی `doctor_secretaries`.** کلید یکتای فعلی:

```php
#[ORM\Table(name: 'doctor_secretaries')]
#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])]
```

`owner_type` فقط `'doctor'` یا `'clinic'` است و `clinic_id` در کلید نیست. نتیجه: **یک منشی نمی‌تواند به یک پزشک در دو کلینیک متفاوت تخصیص یابد** — ردیف دوم با `(doctor_id, secretary_id, 'clinic')` تکراری می‌شود و رد می‌شود. این با سناریوی ۲ ماتریس نقش‌ها (پزشکی که عضو چند کلینیک است) مستقیماً در تضاد است. یکسان‌سازی این کلید را درست می‌کند.

**هدف:** یک املای واحد `(entity_type, entity_id)` با طول یکسان در کل دیتابیس، به‌علاوه رفع باگ یکتایی بالا.

## معیار پذیرش

- ✅ موفق: بعد از migration، این دستور خروجی خالی می‌دهد (هیچ `owner_type` باقی نمانده):
  `ddev exec grep -rn "owner_type\|ownerType" src --include="*.php"`
  و همهٔ ستون‌های `entity_type` طول یکسان دارند. قوانین تخفیف و منشی‌های موجود بدون تغییر رفتار کار می‌کنند.
- ❌ خطا: ساخت `DiscountRule` با محیط حل‌نشده (`EntityContext::unknown()`) → استثنا، نه ردیف با `entity_id = 0`.
- ⚠️ مرزی: **همان منشی، همان پزشک، دو کلینیک متفاوت** → هر دو ردیف با موفقیت ذخیره می‌شوند (قبل از این فاز، ردیف دوم رد می‌شد). این تست باید صریحاً نوشته شود چون رفع یک باگ است، نه صرفاً refactor.

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Discount/Entity/DiscountRule.php` | `owner_type`/`owner_id` → `entity_type`/`entity_id` |
| `src/Discount/Controller/DiscountController.php` | مصرف‌کنندهٔ ستون‌های قدیمی |
| `src/Secretary/Entity/DoctorSecretary.php` | `owner_type` + `clinic_id` → جفت + رفع یکتایی |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | کوئری‌های `owner_type` |
| `src/Secretary/Security/SecretaryAccessChecker.php` | مصرف‌کننده |
| `src/Auth/Entity/UserActiveContext.php` | افزودن `db_type` |
| `src/Shared/Context/EntityContextResolver.php` | حذف حدس دوگانه در `fromActiveContext()` |
| `src/Tag/Entity/TenantTag.php`, `src/Inventory/Entity/InventoryItem.php`, `src/Inventory/Entity/InventoryPackage.php` | طول `entity_type` = ۲۰ → ۱۰ |
| `src/Auth/Entity/MobileVerificationOtp.php` | `entity_type` دارد ولی **معنایش tenant نیست** — بند «نکات مهم» |
| `src/Shared/Tenant/TenantOwnedTrait.php` | trait ساخته‌شده در فاز ۲ |
| `migrations/` | migrationهای rename + backfill |

## وضعیت فعلی

```php
// src/Discount/Entity/DiscountRule.php
#[ORM\Table(name: 'discount_rules')]
#[ORM\Index(columns: ['owner_type', 'owner_id', 'active'], name: 'idx_discount_rules_owner')]
class DiscountRule
{
    #[ORM\Column(name: 'owner_type', type: 'string', length: 10)]
    private string $ownerType;

    #[ORM\Column(name: 'owner_id', type: 'integer')]
    private int $ownerId;
```

```php
// src/Secretary/Entity/DoctorSecretary.php
#[ORM\Table(name: 'doctor_secretaries')]
#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])]
class DoctorSecretary
{
    public const OWNER_DOCTOR = 'doctor';
    public const OWNER_CLINIC = 'clinic';

    #[ORM\Column(name: 'owner_type', type: 'string', length: 10, options: ['default' => 'doctor'])]
    private string $ownerType;

    #[ORM\ManyToOne(targetEntity: Clinic::class)]
    #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
    private ?Clinic $clinic = null;
```

```php
// src/Auth/Entity/UserActiveContext.php
#[ORM\Column(name: 'db_uuid', type: 'string', length: 36)]
private string $dbUuid;
```

## وظایف

### ۱. `discount_rules` — تغییر نام ستون‌ها

ساده‌ترین مورد سری: هیچ تغییر معنایی ندارد، فقط نام.

```php
// src/Discount/Entity/DiscountRule.php
use App\Shared\Tenant\TenantOwnedTrait;

#[ORM\Table(name: 'discount_rules')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_discount_rules_tenant')]
class DiscountRule
{
    use TenantOwnedTrait;
    // ownerType / ownerId حذف می‌شوند
```

migration:

```sql
ALTER TABLE discount_rules
  CHANGE owner_type entity_type VARCHAR(10) NOT NULL,
  CHANGE owner_id   entity_id   INT NOT NULL;

DROP INDEX idx_discount_rules_owner ON discount_rules;
CREATE INDEX idx_discount_rules_tenant ON discount_rules (entity_type, entity_id, active);
```

`CHANGE` به‌جای `ADD`+`UPDATE`+`DROP` استفاده می‌شود چون داده جابه‌جا نمی‌شود، فقط نام ستون — بدون ریسک backfill.

بعد از تغییر entity، همهٔ نقاط مصرف را پیدا و اصلاح کن:

```bash
ddev exec grep -rn "getOwnerType\|getOwnerId\|setOwnerType\|setOwnerId\|owner_type\|owner_id" src assets --include="*.php" --include="*.tsx" --include="*.ts"
```

اگر `owner_type` در پاسخ API تخفیف‌ها ظاهر می‌شود، **نام فیلد پاسخ را عوض نکن** مگر اینکه پنل ادمین را هم در همان جلسه اصلاح کنی — این یک تغییر قرارداد است.

**نحوه تست:**
```bash
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/phpunit tests/           # کل سوئیت
ddev exec php bin/console dbal:run-sql "SELECT entity_type, COUNT(*) FROM discount_rules GROUP BY 1"
```
به‌علاوه تست دستی: صفحهٔ تخفیف‌های پنل ادمین باز شود، یک قانون ساخته و ویرایش شود.

---

### ۲. `doctor_secretaries` — یکسان‌سازی + رفع باگ یکتایی

اینجا فقط تغییر نام نیست: `owner_type` + `clinic_id` تهی‌پذیر باید به جفت واقعی تبدیل شود و کلید یکتا اصلاح گردد.

نگاشت داده:

| وضعیت فعلی | جفت جدید |
|---|---|
| `owner_type='doctor'` (و `clinic_id` تهی) | `('doctor', doctor_id)` |
| `owner_type='clinic'` و `clinic_id = X` | `('clinic', X)` |
| `owner_type='clinic'` ولی `clinic_id` تهی (دادهٔ خراب) | باید شناسایی و گزارش شود — بند «ردیف‌های ناسازگار» |

```php
#[ORM\Table(name: 'doctor_secretaries')]
#[ORM\UniqueConstraint(name: 'uniq_doctor_secretary_scope',
    columns: ['doctor_id', 'secretary_id', 'entity_type', 'entity_id'])]
class DoctorSecretary
{
    use TenantOwnedTrait;
```

migration:

```sql
ALTER TABLE doctor_secretaries ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL;

UPDATE doctor_secretaries
SET entity_type = owner_type,
    entity_id   = IF(owner_type = 'clinic', clinic_id, doctor_id);

-- ردیف‌های ناسازگار: owner_type='clinic' ولی clinic_id تهی
-- SELECT id, doctor_id, secretary_id FROM doctor_secretaries
--   WHERE owner_type='clinic' AND clinic_id IS NULL;

ALTER TABLE doctor_secretaries
  MODIFY entity_type VARCHAR(10) NOT NULL,
  MODIFY entity_id   INT NOT NULL;

DROP INDEX idx_doctor_secretary_scope ON doctor_secretaries;
CREATE UNIQUE INDEX uniq_doctor_secretary_scope
  ON doctor_secretaries (doctor_id, secretary_id, entity_type, entity_id);

ALTER TABLE doctor_secretaries DROP COLUMN owner_type;
```

**ردیف‌های ناسازگار:** در `up()` قبل از `NOT NULL` یک `abortIf` بگذار. اگر چنین ردیف‌هایی وجود داشت، migration باید متوقف شود و شناسه‌ها را در پیام خطا بیاورد — تصمیم دربارهٔ دادهٔ خراب کار انسان است، نه migration:

```php
$broken = $this->connection->fetchFirstColumn(
    "SELECT id FROM doctor_secretaries WHERE owner_type='clinic' AND clinic_id IS NULL"
);
$this->abortIf($broken !== [], 'Inconsistent doctor_secretaries rows: ' . implode(',', $broken));
```

`clinic_id` را نگه دار — `SecretaryAccessChecker` و `DoctorSecretaryRepository::findActiveClinicRow()` مستقیم روی رابطهٔ Doctrine کوئری می‌زنند و حذفش دامنهٔ فاز را می‌ترکاند.

**نحوه تست:**
```bash
ddev exec php bin/phpunit tests/Secretary/
```
به‌علاوه یک تست جدید که باگ رفع‌شده را تثبیت کند — بدون این تست، وظیفه تمام نیست:

```php
// tests/Secretary/SecretaryMultiClinicScopeTest.php
public function testSameSecretaryCanServeSameDoctorInTwoDifferentClinics(): void
{
    // دو کلینیک، یک پزشک عضو هر دو، یک منشی
    // هر دو ردیف باید ذخیره شوند و findActiveClinicRow برای هر کلینیک
    // ردیف مخصوص همان کلینیک را برگرداند
}
```

---

### ۳. `user_active_context` — رفع ابهام `db_uuid`

ستون `db_type` اضافه کن تا `EntityContextResolver` بداند uuid مال چیست و به‌جای دو کوئری، یکی بزند.

```php
// src/Auth/Entity/UserActiveContext.php
#[ORM\Column(name: 'db_type', type: 'string', length: 10)]
private string $dbType;      // 'doctor' | 'clinic'

#[ORM\Column(name: 'db_uuid', type: 'string', length: 36)]
private string $dbUuid;
```

backfill از روی خود داده — uuid را در هر دو جدول بگرد:

```sql
ALTER TABLE user_active_context ADD db_type VARCHAR(10) NULL;

UPDATE user_active_context uac
  JOIN clinics c ON c.uuid = uac.db_uuid
SET uac.db_type = 'clinic';

UPDATE user_active_context uac
  JOIN doctors d ON d.uuid = uac.db_uuid
SET uac.db_type = 'doctor'
WHERE uac.db_type IS NULL;

-- ردیف‌های یتیم (uuid به موجودیت حذف‌شده اشاره می‌کند) پاک شوند:
DELETE FROM user_active_context WHERE db_type IS NULL;

ALTER TABLE user_active_context MODIFY db_type VARCHAR(10) NOT NULL;
```

حذف ردیف‌های یتیم اینجا بی‌خطر است: `user_active_context` صرفاً «آخرین محیط انتخاب‌شده» را کش می‌کند و نبودش یعنی `EntityContextResolver` به `fromRole()` می‌افتد — همان رفتار کاربر تازه‌وارد. **این تنها جدول این سری است که حذف ردیف در آن مجاز است**؛ در هیچ migration دیگری `DELETE` ننویس.

سپس `fromActiveContext()` را ساده کن:

```php
private function fromActiveContext(User $user): ?EntityContext
{
    $active = $this->activeContextRepo->findByUser($user);
    if ($active === null) {
        return null;
    }

    if ($active->getDbType() === EntityContext::TYPE_CLINIC) {
        $clinic = $this->clinicRepo->findByUuid($active->getDbUuid());

        return $clinic !== null && $this->canActInClinic($user, $clinic)
            ? EntityContext::forClinic($clinic)
            : null;
    }

    $doctor = $this->doctorRepo->findByUuid($active->getDbUuid());

    return $doctor !== null && $doctor->getUser()->getId() === $user->getId()
        ? EntityContext::forDoctor($doctor)
        : null;
}
```

هر نقطه‌ای که `UserActiveContext` می‌سازد یا `setDbUuid()` صدا می‌زند باید `db_type` را هم بدهد. نقاط را پیدا کن:

```bash
ddev exec grep -rn "new UserActiveContext(\|setDbUuid(" src --include="*.php"
```

**نحوه تست:**
```bash
ddev exec php bin/phpunit tests/Shared/EntityContextResolverTest.php   # از فاز ۱
ddev exec php bin/console dbal:run-sql "SELECT db_type, COUNT(*) FROM user_active_context GROUP BY 1"
```
تست دستی: سوییچ محیط از پنل ادمین (کاربر سناریوی ۳ — پزشکِ مالک کلینیک) و بررسی اینکه `db_type` درست ذخیره می‌شود.

---

### ۴. یکسان‌سازی طول `entity_type`

چهار جدول `VARCHAR(20)` دارند در حالی که یازده جدول `VARCHAR(10)`:

| جدول | فایل | اقدام |
|---|---|---|
| `tenant_tags` | `src/Tag/Entity/TenantTag.php` | → ۱۰ |
| `inventory_items` | `src/Inventory/Entity/InventoryItem.php` | → ۱۰ |
| `inventory_packages` | `src/Inventory/Entity/InventoryPackage.php` | → ۱۰ |
| `mobile_verification_otp` | `src/Auth/Entity/MobileVerificationOtp.php` | **دست نزن** — بند «نکات مهم» |

قبل از تغییر طول، مطمئن شو هیچ مقدار بلندتری در داده نیست:

```sql
SELECT DISTINCT entity_type, LENGTH(entity_type) FROM tenant_tags;
SELECT DISTINCT entity_type, LENGTH(entity_type) FROM inventory_items;
SELECT DISTINCT entity_type, LENGTH(entity_type) FROM inventory_packages;
```

اگر مقداری غیر از `doctor`/`clinic` بود، **متوقف شو و گزارش بده** — یعنی این ستون معنای دیگری هم دارد و فرض این فاز غلط است.

سپس این سه entity هم `TenantOwnedTrait` را بگیرند تا تعریف تکراری حذف شود.

**نحوه تست:**
```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction   # باید فقط تغییر طول را ببیند
ddev exec php bin/phpunit tests/Inventory/ tests/Tag/
```

---

### ۵. تست ماتریس نقش، تکرار روی داده‌های یکسان‌شده

تست‌های فاز ۱ فقط رزولور را پوشش می‌دادند. حالا که ستون‌ها یکسان شده‌اند، یک تست یکپارچه بنویس که برای **هر پنج نقش** ماتریس، دادهٔ متعلق به محیط درست را از چند دامنه می‌گیرد:

```php
// tests/Shared/TenantIsolationMatrixTest.php
/**
 * @dataProvider roleScenarios
 */
public function testEachRoleSeesOnlyItsOwnTenantData(string $scenario): void
{
    // برای هر نقش: GET روی discounts, patients, services, staff, inventory
    // هیچ رکورد متعلق به tenant دیگر نباید در پاسخ باشد
}
```

این تست ستون فقرات فاز ۴ است — فیلتر خودکار باید همین تست را سبز نگه دارد.

**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantIsolationMatrixTest.php`

## نکات مهم

- **`mobile_verification_otp.entity_type` را دست نزن.** با اینکه نامش یکی است، معنایش tenant نیست — نوع موجودیتی است که کد تأیید برایش صادر شده. قبل از هر تغییری معنایش را از `src/Auth/` تأیید کن؛ اگر واقعاً tenant نبود، در فاز ۴ باید در whitelist استثناها ثبت شود، نه در فیلتر.
- **`CHANGE` در برابر `ADD`+`UPDATE`+`DROP`:** برای `discount_rules` که فقط نام عوض می‌شود، `CHANGE` امن و سریع است. برای `doctor_secretaries` که مقدار جدید از دو ستون ساخته می‌شود، باید سه‌مرحله‌ای باشد. این تفاوت را رعایت کن.
- **رفع باگ یکتایی `doctor_secretaries` یک تغییر رفتار عمدی است** — تنها تغییر رفتاری کل این سری. در گزارش پایانی صریحاً ذکرش کن؛ یعنی از این به بعد ترکیبی که قبلاً رد می‌شد پذیرفته می‌شود.
- **بکاپ قبل از migrate:** `ddev export-db --file=/tmp/pre-tenant-phase3.sql.gz`.
- **الگو: Trait مشترک (ادامهٔ فاز ۲).** دلیل: ستون‌ها و منطق `assignTenant()` یکی‌اند و هفده entity آن را می‌گیرند؛ تکرار تعریف در هر کدام یعنی هفده نقطهٔ واگرایی برای فیلتر فاز ۴.
- **کلاینت‌های متأثر:** اگر نام فیلدی در پاسخ API عوض شد (`owner_type` → `entity_type`)، هم پنل ادمین (`assets/admin/`)، هم `nobat724_front/services/response.js` و هم `clinic-pro-tauri/src/service/response.js` باید دستی بررسی شوند. **پیشنهاد کم‌ریسک:** نام ستون دیتابیس عوض شود ولی کلید JSON پاسخ دست‌نخورده بماند، مگر اینکه هر سه کلاینت در همین جلسه اصلاح شوند.
- **مستندات:** `docs/api/` هر دامنه‌ای که شکل پاسخش عوض شده (`discount`, `secretary`) همان جلسه با JSON واقعی به‌روز شود.
