Files
clinicpro/.claude/prompt/tenant-02-mark-booking-tables.md
hamedandClaude Opus 5 1a7bf53577 refactor(tenant): make EntityContextResolver the single context resolver
Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:59:22 +03:30

349 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# فاز ۲ — نشانه‌گذاری 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 §۴).