# فاز ۳ — یکسان‌سازی نمایش‌های ناهمگون 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 واقعی به‌روز شود.